From 27c954172faff9616206ed287d00c99af479a11e Mon Sep 17 00:00:00 2001 From: reneradoi Date: Mon, 11 Nov 2024 08:53:53 +0000 Subject: [PATCH 01/50] disable allure reports for now (will be added later) --- .github/workflows/ci.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 66e8b82..715b2c6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -80,7 +80,6 @@ jobs: with: juju-agent-version: ${{ matrix.juju.agent }} juju-snap-channel: ${{ matrix.juju.snap_channel }} - _beta_allure_report: ${{ matrix.juju.allure_report }} artifact-prefix: packed-charm-cache-true cloud: lxd permissions: From 7e8b0a7058177220e8826e2be820dbae4ef0a12d Mon Sep 17 00:00:00 2001 From: reneradoi Date: Mon, 11 Nov 2024 12:21:04 +0000 Subject: [PATCH 02/50] implement `on_install` hook --- src/charm.py | 29 ++++++++++++++++++++++++----- src/core/workload.py | 16 ++++++++++++++++ src/literals.py | 30 ++++++++++++++++++++++++++++++ src/workload.py | 39 +++++++++++++++++++++++++++++++++++++++ tests/unit/test_charm.py | 9 +++++++++ 5 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 src/core/workload.py create mode 100644 src/literals.py create mode 100644 src/workload.py diff --git a/src/charm.py b/src/charm.py index 7966b44..1145cea 100755 --- a/src/charm.py +++ b/src/charm.py @@ -7,6 +7,10 @@ import logging import ops +from ops import StatusBase + +from literals import DebugLevel, Status +from workload import EtcdWorkload logger = logging.getLogger(__name__) @@ -16,22 +20,37 @@ class EtcdOperatorCharm(ops.CharmBase): def __init__(self, framework: ops.Framework): super().__init__(framework) - framework.observe(self.on.start, self._on_start) + self.workload = EtcdWorkload() + + # --- CORE EVENTS --- framework.observe(self.on.install, self._on_install) + framework.observe(self.on.start, self._on_start) framework.observe(self.on.config_changed, self._on_config_changed) + def _on_install(self, event: ops.InstallEvent) -> None: + """Handle install event.""" + install = self.workload.install() + if not install: + self._set_status(Status.SERVICE_NOT_INSTALLED) + event.defer() + return + def _on_start(self, event: ops.StartEvent) -> None: """Handle start event.""" self.unit.status = ops.ActiveStatus() - def _on_install(self, event: ops.InstallEvent) -> None: - """Handle install event.""" - pass - def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: """Handle config_changed event.""" pass + def _set_status(self, key: Status) -> None: + """Set charm status.""" + status: StatusBase = key.value.status + log_level: DebugLevel = key.value.log_level + + getattr(logger, log_level.lower())(status.message) + self.unit.status = status + if __name__ == "__main__": # pragma: nocover ops.main(EtcdOperatorCharm) # type: ignore diff --git a/src/core/workload.py b/src/core/workload.py new file mode 100644 index 0000000..aae7628 --- /dev/null +++ b/src/core/workload.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Base objects for workload operations across different substrates.""" + +from abc import ABC, abstractmethod + + +class WorkloadBase(ABC): + """Base interface for common workload operations.""" + + @abstractmethod + def start(self) -> None: + """Start the workload service.""" + pass diff --git a/src/literals.py b/src/literals.py new file mode 100644 index 0000000..289c33a --- /dev/null +++ b/src/literals.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Collection of global literals for the etcd charm.""" + +from dataclasses import dataclass +from enum import Enum +from typing import Literal + +from ops.model import BlockedStatus, StatusBase + +SNAP_NAME = "etcd" +SNAP_REVISION = 233 + +DebugLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"] + + +@dataclass +class StatusLevel: + """Status object helper.""" + + status: StatusBase + log_level: DebugLevel + + +class Status(Enum): + """Collection of possible statuses for the charm.""" + + SERVICE_NOT_INSTALLED = StatusLevel(BlockedStatus("unable to install etcd snap"), "ERROR") diff --git a/src/workload.py b/src/workload.py new file mode 100644 index 0000000..43cdb39 --- /dev/null +++ b/src/workload.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Implementation of WorkloadBase for running on VMs.""" + +import logging + +from charms.operator_libs_linux.v2 import snap +from typing_extensions import override + +from core.workload import WorkloadBase +from literals import SNAP_NAME, SNAP_REVISION + +logger = logging.getLogger(__name__) + + +class EtcdWorkload(WorkloadBase): + """Implementation of WorkloadBase for running on VMs.""" + + def __init__(self): + self.etcd = snap.SnapCache()[SNAP_NAME] + + @override + def start(self) -> None: + pass + + def install(self) -> bool: + """Install the etcd snap from the snap store. + + Returns: + True if successfully installed, False if any error occurs. + """ + try: + self.etcd.ensure(snap.SnapState.Present, revision=SNAP_REVISION) + self.etcd.hold() + except snap.SnapError as e: + logger.error(str(e)) + return False diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index a876834..b55e0ab 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -7,6 +7,7 @@ import ops import ops.testing from scenario import Context, State +from unittest.mock import patch from charm import EtcdOperatorCharm @@ -17,3 +18,11 @@ def test_start(self): state_in = State() state_out = ctx.run(ctx.on.start(), state_in) assert state_out.unit_status == ops.ActiveStatus() + + def test_install_failure_blocked_status(self): + ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) + state_in = State() + + with patch("workload.EtcdWorkload.install", return_value=False): + state_out = ctx.run(ctx.on.install(), state_in) + assert state_out.unit_status == ops.BlockedStatus("unable to install etcd snap") From 262bacd4511a18699ca88f7e466df4617062ea8c Mon Sep 17 00:00:00 2001 From: reneradoi Date: Mon, 11 Nov 2024 12:41:50 +0000 Subject: [PATCH 03/50] fix linting, add snap lib --- lib/charms/operator_libs_linux/v2/snap.py | 1160 +++++++++++++++++++++ tests/unit/test_charm.py | 2 +- 2 files changed, 1161 insertions(+), 1 deletion(-) create mode 100644 lib/charms/operator_libs_linux/v2/snap.py diff --git a/lib/charms/operator_libs_linux/v2/snap.py b/lib/charms/operator_libs_linux/v2/snap.py new file mode 100644 index 0000000..9d09a78 --- /dev/null +++ b/lib/charms/operator_libs_linux/v2/snap.py @@ -0,0 +1,1160 @@ +# Copyright 2021 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Representations of the system's Snaps, and abstractions around managing them. + +The `snap` module provides convenience methods for listing, installing, refreshing, and removing +Snap packages, in addition to setting and getting configuration options for them. + +In the `snap` module, `SnapCache` creates a dict-like mapping of `Snap` objects at when +instantiated. Installed snaps are fully populated, and available snaps are lazily-loaded upon +request. This module relies on an installed and running `snapd` daemon to perform operations over +the `snapd` HTTP API. + +`SnapCache` objects can be used to install or modify Snap packages by name in a manner similar to +using the `snap` command from the commandline. + +An example of adding Juju to the system with `SnapCache` and setting a config value: + +```python +try: + cache = snap.SnapCache() + juju = cache["juju"] + + if not juju.present: + juju.ensure(snap.SnapState.Latest, channel="beta") + juju.set({"some.key": "value", "some.key2": "value2"}) +except snap.SnapError as e: + logger.error("An exception occurred when installing charmcraft. Reason: %s", e.message) +``` + +In addition, the `snap` module provides "bare" methods which can act on Snap packages as +simple function calls. :meth:`add`, :meth:`remove`, and :meth:`ensure` are provided, as +well as :meth:`add_local` for installing directly from a local `.snap` file. These return +`Snap` objects. + +As an example of installing several Snaps and checking details: + +```python +try: + nextcloud, charmcraft = snap.add(["nextcloud", "charmcraft"]) + if nextcloud.get("mode") != "production": + nextcloud.set({"mode": "production"}) +except snap.SnapError as e: + logger.error("An exception occurred when installing snaps. Reason: %s" % e.message) +``` +""" + +import http.client +import json +import logging +import os +import re +import socket +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Mapping +from datetime import datetime, timedelta, timezone +from enum import Enum +from subprocess import CalledProcessError, CompletedProcess +from typing import Any, Dict, Iterable, List, Optional, Union + +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "05394e5893f94f2d90feb7cbe6b633cd" + +# Increment this major API version when introducing breaking changes +LIBAPI = 2 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 7 + + +# Regex to locate 7-bit C1 ANSI sequences +ansi_filter = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + + +def _cache_init(func): + def inner(*args, **kwargs): + if _Cache.cache is None: + _Cache.cache = SnapCache() + return func(*args, **kwargs) + + return inner + + +# recursive hints seems to error out pytest +JSONType = Union[Dict[str, Any], List[Any], str, int, float] + + +class SnapService: + """Data wrapper for snap services.""" + + def __init__( + self, + daemon: Optional[str] = None, + daemon_scope: Optional[str] = None, + enabled: bool = False, + active: bool = False, + activators: List[str] = [], + **kwargs, + ): + self.daemon = daemon + self.daemon_scope = kwargs.get("daemon-scope", None) or daemon_scope + self.enabled = enabled + self.active = active + self.activators = activators + + def as_dict(self) -> Dict: + """Return instance representation as dict.""" + return { + "daemon": self.daemon, + "daemon_scope": self.daemon_scope, + "enabled": self.enabled, + "active": self.active, + "activators": self.activators, + } + + +class MetaCache(type): + """MetaCache class used for initialising the snap cache.""" + + @property + def cache(cls) -> "SnapCache": + """Property for returning the snap cache.""" + return cls._cache + + @cache.setter + def cache(cls, cache: "SnapCache") -> None: + """Setter for the snap cache.""" + cls._cache = cache + + def __getitem__(cls, name) -> "Snap": + """Snap cache getter.""" + return cls._cache[name] + + +class _Cache(object, metaclass=MetaCache): + _cache = None + + +class Error(Exception): + """Base class of most errors raised by this library.""" + + def __repr__(self): + """Represent the Error class.""" + return "<{}.{} {}>".format(type(self).__module__, type(self).__name__, self.args) + + @property + def name(self): + """Return a string representation of the model plus class.""" + return "<{}.{}>".format(type(self).__module__, type(self).__name__) + + @property + def message(self): + """Return the message passed as an argument.""" + return self.args[0] + + +class SnapAPIError(Error): + """Raised when an HTTP API error occurs talking to the Snapd server.""" + + def __init__(self, body: Dict, code: int, status: str, message: str): + super().__init__(message) # Makes str(e) return message + self.body = body + self.code = code + self.status = status + self._message = message + + def __repr__(self): + """Represent the SnapAPIError class.""" + return "APIError({!r}, {!r}, {!r}, {!r})".format( + self.body, self.code, self.status, self._message + ) + + +class SnapState(Enum): + """The state of a snap on the system or in the cache.""" + + Present = "present" + Absent = "absent" + Latest = "latest" + Available = "available" + + +class SnapError(Error): + """Raised when there's an error running snap control commands.""" + + +class SnapNotFoundError(Error): + """Raised when a requested snap is not known to the system.""" + + +class Snap(object): + """Represents a snap package and its properties. + + `Snap` exposes the following properties about a snap: + - name: the name of the snap + - state: a `SnapState` representation of its install status + - channel: "stable", "candidate", "beta", and "edge" are common + - revision: a string representing the snap's revision + - confinement: "classic", "strict", or "devmode" + """ + + def __init__( + self, + name, + state: SnapState, + channel: str, + revision: str, + confinement: str, + apps: Optional[List[Dict[str, str]]] = None, + cohort: Optional[str] = "", + ) -> None: + self._name = name + self._state = state + self._channel = channel + self._revision = revision + self._confinement = confinement + self._cohort = cohort + self._apps = apps or [] + self._snap_client = SnapClient() + + def __eq__(self, other) -> bool: + """Equality for comparison.""" + return isinstance(other, self.__class__) and ( + self._name, + self._revision, + ) == (other._name, other._revision) + + def __hash__(self): + """Calculate a hash for this snap.""" + return hash((self._name, self._revision)) + + def __repr__(self): + """Represent the object such that it can be reconstructed.""" + return "<{}.{}: {}>".format(self.__module__, self.__class__.__name__, self.__dict__) + + def __str__(self): + """Represent the snap object as a string.""" + return "<{}: {}-{}.{} -- {}>".format( + self.__class__.__name__, + self._name, + self._revision, + self._channel, + str(self._state), + ) + + def _snap(self, command: str, optargs: Optional[Iterable[str]] = None) -> str: + """Perform a snap operation. + + Args: + command: the snap command to execute + optargs: an (optional) list of additional arguments to pass, + commonly confinement or channel + + Raises: + SnapError if there is a problem encountered + """ + optargs = optargs or [] + args = ["snap", command, self._name, *optargs] + try: + return subprocess.check_output(args, universal_newlines=True) + except CalledProcessError as e: + raise SnapError( + "Snap: {!r}; command {!r} failed with output = {!r}".format( + self._name, args, e.output + ) + ) + + def _snap_daemons( + self, + command: List[str], + services: Optional[List[str]] = None, + ) -> CompletedProcess: + """Perform snap app commands. + + Args: + command: the snap command to execute + services: the snap service to execute command on + + Raises: + SnapError if there is a problem encountered + """ + if services: + # an attempt to keep the command constrained to the snap instance's services + services = ["{}.{}".format(self._name, service) for service in services] + else: + services = [self._name] + + args = ["snap", *command, *services] + + try: + return subprocess.run(args, universal_newlines=True, check=True, capture_output=True) + except CalledProcessError as e: + raise SnapError("Could not {} for snap [{}]: {}".format(args, self._name, e.stderr)) + + def get(self, key: Optional[str], *, typed: bool = False) -> Any: + """Fetch snap configuration values. + + Args: + key: the key to retrieve. Default to retrieve all values for typed=True. + typed: set to True to retrieve typed values (set with typed=True). + Default is to return a string. + """ + if typed: + args = ["-d"] + if key: + args.append(key) + config = json.loads(self._snap("get", args)) + if key: + return config.get(key) + return config + + if not key: + raise TypeError("Key must be provided when typed=False") + + return self._snap("get", [key]).strip() + + def set(self, config: Dict[str, Any], *, typed: bool = False) -> str: + """Set a snap configuration value. + + Args: + config: a dictionary containing keys and values specifying the config to set. + typed: set to True to convert all values in the config into typed values while + configuring the snap (set with typed=True). Default is not to convert. + """ + if typed: + kv = [f"{key}={json.dumps(val)}" for key, val in config.items()] + return self._snap("set", ["-t"] + kv) + + return self._snap("set", [f"{key}={val}" for key, val in config.items()]) + + def unset(self, key) -> str: + """Unset a snap configuration value. + + Args: + key: the key to unset + """ + return self._snap("unset", [key]) + + def start(self, services: Optional[List[str]] = None, enable: Optional[bool] = False) -> None: + """Start a snap's services. + + Args: + services (list): (optional) list of individual snap services to start (otherwise all) + enable (bool): (optional) flag to enable snap services on start. Default `false` + """ + args = ["start", "--enable"] if enable else ["start"] + self._snap_daemons(args, services) + + def stop(self, services: Optional[List[str]] = None, disable: Optional[bool] = False) -> None: + """Stop a snap's services. + + Args: + services (list): (optional) list of individual snap services to stop (otherwise all) + disable (bool): (optional) flag to disable snap services on stop. Default `False` + """ + args = ["stop", "--disable"] if disable else ["stop"] + self._snap_daemons(args, services) + + def logs(self, services: Optional[List[str]] = None, num_lines: Optional[int] = 10) -> str: + """Fetch a snap services' logs. + + Args: + services (list): (optional) list of individual snap services to show logs from + (otherwise all) + num_lines (int): (optional) integer number of log lines to return. Default `10` + """ + args = ["logs", "-n={}".format(num_lines)] if num_lines else ["logs"] + return self._snap_daemons(args, services).stdout + + def connect( + self, plug: str, service: Optional[str] = None, slot: Optional[str] = None + ) -> None: + """Connect a plug to a slot. + + Args: + plug (str): the plug to connect + service (str): (optional) the snap service name to plug into + slot (str): (optional) the snap service slot to plug in to + + Raises: + SnapError if there is a problem encountered + """ + command = ["connect", "{}:{}".format(self._name, plug)] + + if service and slot: + command = command + ["{}:{}".format(service, slot)] + elif slot: + command = command + [slot] + + args = ["snap", *command] + try: + subprocess.run(args, universal_newlines=True, check=True, capture_output=True) + except CalledProcessError as e: + raise SnapError("Could not {} for snap [{}]: {}".format(args, self._name, e.stderr)) + + def hold(self, duration: Optional[timedelta] = None) -> None: + """Add a refresh hold to a snap. + + Args: + duration: duration for the hold, or None (the default) to hold this snap indefinitely. + """ + hold_str = "forever" + if duration is not None: + seconds = round(duration.total_seconds()) + hold_str = f"{seconds}s" + self._snap("refresh", [f"--hold={hold_str}"]) + + def unhold(self) -> None: + """Remove the refresh hold of a snap.""" + self._snap("refresh", ["--unhold"]) + + def alias(self, application: str, alias: Optional[str] = None) -> None: + """Create an alias for a given application. + + Args: + application: application to get an alias. + alias: (optional) name of the alias; if not provided, the application name is used. + """ + if alias is None: + alias = application + args = ["snap", "alias", f"{self.name}.{application}", alias] + try: + subprocess.check_output(args, universal_newlines=True) + except CalledProcessError as e: + raise SnapError( + "Snap: {!r}; command {!r} failed with output = {!r}".format( + self._name, args, e.output + ) + ) + + def restart( + self, services: Optional[List[str]] = None, reload: Optional[bool] = False + ) -> None: + """Restarts a snap's services. + + Args: + services (list): (optional) list of individual snap services to restart. + (otherwise all) + reload (bool): (optional) flag to use the service reload command, if available. + Default `False` + """ + args = ["restart", "--reload"] if reload else ["restart"] + self._snap_daemons(args, services) + + def _install( + self, + channel: Optional[str] = "", + cohort: Optional[str] = "", + revision: Optional[str] = None, + ) -> None: + """Add a snap to the system. + + Args: + channel: the channel to install from + cohort: optional, the key of a cohort that this snap belongs to + revision: optional, the revision of the snap to install + """ + cohort = cohort or self._cohort + + args = [] + if self.confinement == "classic": + args.append("--classic") + if self.confinement == "devmode": + args.append("--devmode") + if channel: + args.append('--channel="{}"'.format(channel)) + if revision: + args.append('--revision="{}"'.format(revision)) + if cohort: + args.append('--cohort="{}"'.format(cohort)) + + self._snap("install", args) + + def _refresh( + self, + channel: Optional[str] = "", + cohort: Optional[str] = "", + revision: Optional[str] = None, + devmode: bool = False, + leave_cohort: Optional[bool] = False, + ) -> None: + """Refresh a snap. + + Args: + channel: the channel to install from + cohort: optionally, specify a cohort. + revision: optionally, specify the revision of the snap to refresh + devmode: optionally, specify devmode confinement + leave_cohort: leave the current cohort. + """ + args = [] + if channel: + args.append('--channel="{}"'.format(channel)) + + if revision: + args.append('--revision="{}"'.format(revision)) + + if devmode: + args.append("--devmode") + + if not cohort: + cohort = self._cohort + + if leave_cohort: + self._cohort = "" + args.append("--leave-cohort") + elif cohort: + args.append('--cohort="{}"'.format(cohort)) + + self._snap("refresh", args) + + def _remove(self) -> str: + """Remove a snap from the system.""" + return self._snap("remove") + + @property + def name(self) -> str: + """Returns the name of the snap.""" + return self._name + + def ensure( + self, + state: SnapState, + classic: Optional[bool] = False, + devmode: bool = False, + channel: Optional[str] = "", + cohort: Optional[str] = "", + revision: Optional[str] = None, + ): + """Ensure that a snap is in a given state. + + Args: + state: a `SnapState` to reconcile to. + classic: an (Optional) boolean indicating whether classic confinement should be used + devmode: an (Optional) boolean indicating whether devmode confinement should be used + channel: the channel to install from + cohort: optional. Specify the key of a snap cohort. + revision: optional. the revision of the snap to install/refresh + + While both channel and revision could be specified, the underlying snap install/refresh + command will determine which one takes precedence (revision at this time) + + Raises: + SnapError if an error is encountered + """ + if classic and devmode: + raise ValueError("Cannot set both classic and devmode confinement") + + if classic or self._confinement == "classic": + self._confinement = "classic" + elif devmode or self._confinement == "devmode": + self._confinement = "devmode" + else: + self._confinement = "" + + if state not in (SnapState.Present, SnapState.Latest): + # We are attempting to remove this snap. + if self._state in (SnapState.Present, SnapState.Latest): + # The snap is installed, so we run _remove. + self._remove() + else: + # The snap is not installed -- no need to do anything. + pass + else: + # We are installing or refreshing a snap. + if self._state not in (SnapState.Present, SnapState.Latest): + # The snap is not installed, so we install it. + logger.info( + "Installing snap %s, revision %s, tracking %s", self._name, revision, channel + ) + self._install(channel, cohort, revision) + logger.info("The snap installation completed successfully") + elif revision is None or revision != self._revision: + # The snap is installed, but we are changing it (e.g., switching channels). + logger.info( + "Refreshing snap %s, revision %s, tracking %s", self._name, revision, channel + ) + self._refresh(channel=channel, cohort=cohort, revision=revision, devmode=devmode) + logger.info("The snap refresh completed successfully") + else: + logger.info("Refresh of snap %s was unnecessary", self._name) + + self._update_snap_apps() + self._state = state + + def _update_snap_apps(self) -> None: + """Update a snap's apps after snap changes state.""" + try: + self._apps = self._snap_client.get_installed_snap_apps(self._name) + except SnapAPIError: + logger.debug("Unable to retrieve snap apps for {}".format(self._name)) + self._apps = [] + + @property + def present(self) -> bool: + """Report whether or not a snap is present.""" + return self._state in (SnapState.Present, SnapState.Latest) + + @property + def latest(self) -> bool: + """Report whether the snap is the most recent version.""" + return self._state is SnapState.Latest + + @property + def state(self) -> SnapState: + """Report the current snap state.""" + return self._state + + @state.setter + def state(self, state: SnapState) -> None: + """Set the snap state to a given value. + + Args: + state: a `SnapState` to reconcile the snap to. + + Raises: + SnapError if an error is encountered + """ + if self._state is not state: + self.ensure(state) + self._state = state + + @property + def revision(self) -> str: + """Returns the revision for a snap.""" + return self._revision + + @property + def channel(self) -> str: + """Returns the channel for a snap.""" + return self._channel + + @property + def confinement(self) -> str: + """Returns the confinement for a snap.""" + return self._confinement + + @property + def apps(self) -> List: + """Returns (if any) the installed apps of the snap.""" + self._update_snap_apps() + return self._apps + + @property + def services(self) -> Dict: + """Returns (if any) the installed services of the snap.""" + self._update_snap_apps() + services = {} + for app in self._apps: + if "daemon" in app: + services[app["name"]] = SnapService(**app).as_dict() + + return services + + @property + def held(self) -> bool: + """Report whether the snap has a hold.""" + info = self._snap("info") + return "hold:" in info + + +class _UnixSocketConnection(http.client.HTTPConnection): + """Implementation of HTTPConnection that connects to a named Unix socket.""" + + def __init__(self, host, timeout=None, socket_path=None): + if timeout is None: + super().__init__(host) + else: + super().__init__(host, timeout=timeout) + self.socket_path = socket_path + + def connect(self): + """Override connect to use Unix socket (instead of TCP socket).""" + if not hasattr(socket, "AF_UNIX"): + raise NotImplementedError("Unix sockets not supported on {}".format(sys.platform)) + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(self.socket_path) + if self.timeout is not None: + self.sock.settimeout(self.timeout) + + +class _UnixSocketHandler(urllib.request.AbstractHTTPHandler): + """Implementation of HTTPHandler that uses a named Unix socket.""" + + def __init__(self, socket_path: str): + super().__init__() + self.socket_path = socket_path + + def http_open(self, req) -> http.client.HTTPResponse: + """Override http_open to use a Unix socket connection (instead of TCP).""" + return self.do_open(_UnixSocketConnection, req, socket_path=self.socket_path) + + +class SnapClient: + """Snapd API client to talk to HTTP over UNIX sockets. + + In order to avoid shelling out and/or involving sudo in calling the snapd API, + use a wrapper based on the Pebble Client, trimmed down to only the utility methods + needed for talking to snapd. + """ + + def __init__( + self, + socket_path: str = "/run/snapd.socket", + opener: Optional[urllib.request.OpenerDirector] = None, + base_url: str = "http://localhost/v2/", + timeout: float = 30.0, + ): + """Initialize a client instance. + + Args: + socket_path: a path to the socket on the filesystem. Defaults to /run/snap/snapd.socket + opener: specifies an opener for unix socket, if unspecified a default is used + base_url: base url for making requests to the snap client. Defaults to + http://localhost/v2/ + timeout: timeout in seconds to use when making requests to the API. Default is 30.0s. + """ + if opener is None: + opener = self._get_default_opener(socket_path) + self.opener = opener + self.base_url = base_url + self.timeout = timeout + + @classmethod + def _get_default_opener(cls, socket_path): + """Build the default opener to use for requests (HTTP over Unix socket).""" + opener = urllib.request.OpenerDirector() + opener.add_handler(_UnixSocketHandler(socket_path)) + opener.add_handler(urllib.request.HTTPDefaultErrorHandler()) + opener.add_handler(urllib.request.HTTPRedirectHandler()) + opener.add_handler(urllib.request.HTTPErrorProcessor()) + return opener + + def _request( + self, + method: str, + path: str, + query: Dict = None, + body: Dict = None, + ) -> JSONType: + """Make a JSON request to the Snapd server with the given HTTP method and path. + + If query dict is provided, it is encoded and appended as a query string + to the URL. If body dict is provided, it is serialied as JSON and used + as the HTTP body (with Content-Type: "application/json"). The resulting + body is decoded from JSON. + """ + headers = {"Accept": "application/json"} + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + + response = self._request_raw(method, path, query, headers, data) + return json.loads(response.read().decode())["result"] + + def _request_raw( + self, + method: str, + path: str, + query: Dict = None, + headers: Dict = None, + data: bytes = None, + ) -> http.client.HTTPResponse: + """Make a request to the Snapd server; return the raw HTTPResponse object.""" + url = self.base_url + path + if query: + url = url + "?" + urllib.parse.urlencode(query) + + if headers is None: + headers = {} + request = urllib.request.Request(url, method=method, data=data, headers=headers) + + try: + response = self.opener.open(request, timeout=self.timeout) + except urllib.error.HTTPError as e: + code = e.code + status = e.reason + message = "" + try: + body = json.loads(e.read().decode())["result"] + except (IOError, ValueError, KeyError) as e2: + # Will only happen on read error or if Pebble sends invalid JSON. + body = {} + message = "{} - {}".format(type(e2).__name__, e2) + raise SnapAPIError(body, code, status, message) + except urllib.error.URLError as e: + raise SnapAPIError({}, 500, "Not found", e.reason) + return response + + def get_installed_snaps(self) -> Dict: + """Get information about currently installed snaps.""" + return self._request("GET", "snaps") + + def get_snap_information(self, name: str) -> Dict: + """Query the snap server for information about single snap.""" + return self._request("GET", "find", {"name": name})[0] + + def get_installed_snap_apps(self, name: str) -> List: + """Query the snap server for apps belonging to a named, currently installed snap.""" + return self._request("GET", "apps", {"names": name, "select": "service"}) + + +class SnapCache(Mapping): + """An abstraction to represent installed/available packages. + + When instantiated, `SnapCache` iterates through the list of installed + snaps using the `snapd` HTTP API, and a list of available snaps by reading + the filesystem to populate the cache. Information about available snaps is lazily-loaded + from the `snapd` API when requested. + """ + + def __init__(self): + if not self.snapd_installed: + raise SnapError("snapd is not installed or not in /usr/bin") from None + self._snap_client = SnapClient() + self._snap_map = {} + if self.snapd_installed: + self._load_available_snaps() + self._load_installed_snaps() + + def __contains__(self, key: str) -> bool: + """Check if a given snap is in the cache.""" + return key in self._snap_map + + def __len__(self) -> int: + """Report number of items in the snap cache.""" + return len(self._snap_map) + + def __iter__(self) -> Iterable["Snap"]: + """Provide iterator for the snap cache.""" + return iter(self._snap_map.values()) + + def __getitem__(self, snap_name: str) -> Snap: + """Return either the installed version or latest version for a given snap.""" + snap = self._snap_map.get(snap_name, None) + if snap is None: + # The snapd cache file may not have existed when _snap_map was + # populated. This is normal. + try: + self._snap_map[snap_name] = self._load_info(snap_name) + except SnapAPIError: + raise SnapNotFoundError("Snap '{}' not found!".format(snap_name)) + + return self._snap_map[snap_name] + + @property + def snapd_installed(self) -> bool: + """Check whether snapd has been installled on the system.""" + return os.path.isfile("/usr/bin/snap") + + def _load_available_snaps(self) -> None: + """Load the list of available snaps from disk. + + Leave them empty and lazily load later if asked for. + """ + if not os.path.isfile("/var/cache/snapd/names"): + # The snap catalog may not be populated yet; this is normal. + # snapd updates the cache infrequently and the cache file may not + # currently exist. + return + + with open("/var/cache/snapd/names", "r") as f: + for line in f: + if line.strip(): + self._snap_map[line.strip()] = None + + def _load_installed_snaps(self) -> None: + """Load the installed snaps into the dict.""" + installed = self._snap_client.get_installed_snaps() + + for i in installed: + snap = Snap( + name=i["name"], + state=SnapState.Latest, + channel=i["channel"], + revision=i["revision"], + confinement=i["confinement"], + apps=i.get("apps", None), + ) + self._snap_map[snap.name] = snap + + def _load_info(self, name) -> Snap: + """Load info for snaps which are not installed if requested. + + Args: + name: a string representing the name of the snap + """ + info = self._snap_client.get_snap_information(name) + + return Snap( + name=info["name"], + state=SnapState.Available, + channel=info["channel"], + revision=info["revision"], + confinement=info["confinement"], + apps=None, + ) + + +@_cache_init +def add( + snap_names: Union[str, List[str]], + state: Union[str, SnapState] = SnapState.Latest, + channel: Optional[str] = "", + classic: Optional[bool] = False, + devmode: bool = False, + cohort: Optional[str] = "", + revision: Optional[str] = None, +) -> Union[Snap, List[Snap]]: + """Add a snap to the system. + + Args: + snap_names: the name or names of the snaps to install + state: a string or `SnapState` representation of the desired state, one of + [`Present` or `Latest`] + channel: an (Optional) channel as a string. Defaults to 'latest' + classic: an (Optional) boolean specifying whether it should be added with classic + confinement. Default `False` + devmode: an (Optional) boolean specifying whether it should be added with devmode + confinement. Default `False` + cohort: an (Optional) string specifying the snap cohort to use + revision: an (Optional) string specifying the snap revision to use + + Raises: + SnapError if some snaps failed to install or were not found. + """ + if not channel and not revision: + channel = "latest" + + snap_names = [snap_names] if isinstance(snap_names, str) else snap_names + if not snap_names: + raise TypeError("Expected at least one snap to add, received zero!") + + if isinstance(state, str): + state = SnapState(state) + + return _wrap_snap_operations(snap_names, state, channel, classic, devmode, cohort, revision) + + +@_cache_init +def remove(snap_names: Union[str, List[str]]) -> Union[Snap, List[Snap]]: + """Remove specified snap(s) from the system. + + Args: + snap_names: the name or names of the snaps to install + + Raises: + SnapError if some snaps failed to install. + """ + snap_names = [snap_names] if isinstance(snap_names, str) else snap_names + if not snap_names: + raise TypeError("Expected at least one snap to add, received zero!") + return _wrap_snap_operations( + snap_names=snap_names, + state=SnapState.Absent, + channel="", + classic=False, + devmode=False, + ) + + +@_cache_init +def ensure( + snap_names: Union[str, List[str]], + state: str, + channel: Optional[str] = "", + classic: Optional[bool] = False, + devmode: bool = False, + cohort: Optional[str] = "", + revision: Optional[int] = None, +) -> Union[Snap, List[Snap]]: + """Ensure specified snaps are in a given state on the system. + + Args: + snap_names: the name(s) of the snaps to operate on + state: a string representation of the desired state, from `SnapState` + channel: an (Optional) channel as a string. Defaults to 'latest' + classic: an (Optional) boolean specifying whether it should be added with classic + confinement. Default `False` + devmode: an (Optional) boolean specifying whether it should be added with devmode + confinement. Default `False` + cohort: an (Optional) string specifying the snap cohort to use + revision: an (Optional) integer specifying the snap revision to use + + When both channel and revision are specified, the underlying snap install/refresh + command will determine the precedence (revision at the time of adding this) + + Raises: + SnapError if the snap is not in the cache. + """ + if not revision and not channel: + channel = "latest" + + if state in ("present", "latest") or revision: + return add( + snap_names=snap_names, + state=SnapState(state), + channel=channel, + classic=classic, + devmode=devmode, + cohort=cohort, + revision=revision, + ) + else: + return remove(snap_names) + + +def _wrap_snap_operations( + snap_names: List[str], + state: SnapState, + channel: str, + classic: bool, + devmode: bool, + cohort: Optional[str] = "", + revision: Optional[str] = None, +) -> Union[Snap, List[Snap]]: + """Wrap common operations for bare commands.""" + snaps = {"success": [], "failed": []} + + op = "remove" if state is SnapState.Absent else "install or refresh" + + for s in snap_names: + try: + snap = _Cache[s] + if state is SnapState.Absent: + snap.ensure(state=SnapState.Absent) + else: + snap.ensure( + state=state, + classic=classic, + devmode=devmode, + channel=channel, + cohort=cohort, + revision=revision, + ) + snaps["success"].append(snap) + except SnapError as e: + logger.warning("Failed to {} snap {}: {}!".format(op, s, e.message)) + snaps["failed"].append(s) + except SnapNotFoundError: + logger.warning("Snap '{}' not found in cache!".format(s)) + snaps["failed"].append(s) + + if len(snaps["failed"]): + raise SnapError( + "Failed to install or refresh snap(s): {}".format(", ".join(list(snaps["failed"]))) + ) + + return snaps["success"] if len(snaps["success"]) > 1 else snaps["success"][0] + + +def install_local( + filename: str, + classic: Optional[bool] = False, + devmode: Optional[bool] = False, + dangerous: Optional[bool] = False, +) -> Snap: + """Perform a snap operation. + + Args: + filename: the path to a local .snap file to install + classic: whether to use classic confinement + devmode: whether to use devmode confinement + dangerous: whether --dangerous should be passed to install snaps without a signature + + Raises: + SnapError if there is a problem encountered + """ + args = [ + "snap", + "install", + filename, + ] + if classic: + args.append("--classic") + if devmode: + args.append("--devmode") + if dangerous: + args.append("--dangerous") + try: + result = subprocess.check_output(args, universal_newlines=True).splitlines()[-1] + snap_name, _ = result.split(" ", 1) + snap_name = ansi_filter.sub("", snap_name) + + c = SnapCache() + + try: + return c[snap_name] + except SnapAPIError as e: + logger.error( + "Could not find snap {} when querying Snapd socket: {}".format(snap_name, e.body) + ) + raise SnapError("Failed to find snap {} in Snap cache".format(snap_name)) + except CalledProcessError as e: + raise SnapError("Could not install snap {}: {}".format(filename, e.output)) + + +def _system_set(config_item: str, value: str) -> None: + """Set system snapd config values. + + Args: + config_item: name of snap system setting. E.g. 'refresh.hold' + value: value to assign + """ + args = ["snap", "set", "system", "{}={}".format(config_item, value)] + try: + subprocess.check_call(args, universal_newlines=True) + except CalledProcessError: + raise SnapError("Failed setting system config '{}' to '{}'".format(config_item, value)) + + +def hold_refresh(days: int = 90, forever: bool = False) -> bool: + """Set the system-wide snap refresh hold. + + Args: + days: number of days to hold system refreshes for. Maximum 90. Set to zero to remove hold. + forever: if True, will set a hold forever. + """ + if not isinstance(forever, bool): + raise TypeError("forever must be a bool") + if not isinstance(days, int): + raise TypeError("days must be an int") + if forever: + _system_set("refresh.hold", "forever") + logger.info("Set system-wide snap refresh hold to: forever") + elif days == 0: + _system_set("refresh.hold", "") + logger.info("Removed system-wide snap refresh hold") + else: + # Currently the snap daemon can only hold for a maximum of 90 days + if not 1 <= days <= 90: + raise ValueError("days must be between 1 and 90") + # Add the number of days to current time + target_date = datetime.now(timezone.utc).astimezone() + timedelta(days=days) + # Format for the correct datetime format + hold_date = target_date.strftime("%Y-%m-%dT%H:%M:%S%z") + # Python dumps the offset in format '+0100', we need '+01:00' + hold_date = "{0}:{1}".format(hold_date[:-2], hold_date[-2:]) + # Actually set the hold date + _system_set("refresh.hold", hold_date) + logger.info("Set system-wide snap refresh hold to: %s", hold_date) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index b55e0ab..457d306 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -3,11 +3,11 @@ # See LICENSE file for licensing details. import unittest +from unittest.mock import patch import ops import ops.testing from scenario import Context, State -from unittest.mock import patch from charm import EtcdOperatorCharm From 28c34ef57601fd174c8ae024161144e004a25227 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Tue, 12 Nov 2024 16:20:48 +0000 Subject: [PATCH 04/50] implement peer relation handler --- .../data_platform_libs/v0/data_interfaces.py | 3758 +++++++++++++++++ metadata.yaml | 5 +- poetry.lock | 221 +- pyproject.toml | 5 +- src/charm.py | 62 +- src/core/cluster.py | 43 + src/core/models.py | 92 + src/literals.py | 10 +- src/managers/cluster.py | 33 + src/workload.py | 1 + tests/unit/test_charm.py | 25 +- 11 files changed, 4111 insertions(+), 144 deletions(-) create mode 100644 lib/charms/data_platform_libs/v0/data_interfaces.py create mode 100644 src/core/cluster.py create mode 100644 src/core/models.py create mode 100644 src/managers/cluster.py diff --git a/lib/charms/data_platform_libs/v0/data_interfaces.py b/lib/charms/data_platform_libs/v0/data_interfaces.py new file mode 100644 index 0000000..3bc2dd8 --- /dev/null +++ b/lib/charms/data_platform_libs/v0/data_interfaces.py @@ -0,0 +1,3758 @@ +# Copyright 2023 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +r"""Library to manage the relation for the data-platform products. + +This library contains the Requires and Provides classes for handling the relation +between an application and multiple managed application supported by the data-team: +MySQL, Postgresql, MongoDB, Redis, and Kafka. + +### Database (MySQL, Postgresql, MongoDB, and Redis) + +#### Requires Charm +This library is a uniform interface to a selection of common database +metadata, with added custom events that add convenience to database management, +and methods to consume the application related data. + + +Following an example of using the DatabaseCreatedEvent, in the context of the +application charm code: + +```python + +from charms.data_platform_libs.v0.data_interfaces import ( + DatabaseCreatedEvent, + DatabaseRequires, +) + +class ApplicationCharm(CharmBase): + # Application charm that connects to database charms. + + def __init__(self, *args): + super().__init__(*args) + + # Charm events defined in the database requires charm library. + self.database = DatabaseRequires(self, relation_name="database", database_name="database") + self.framework.observe(self.database.on.database_created, self._on_database_created) + + def _on_database_created(self, event: DatabaseCreatedEvent) -> None: + # Handle the created database + + # Create configuration file for app + config_file = self._render_app_config_file( + event.username, + event.password, + event.endpoints, + ) + + # Start application with rendered configuration + self._start_application(config_file) + + # Set active status + self.unit.status = ActiveStatus("received database credentials") +``` + +As shown above, the library provides some custom events to handle specific situations, +which are listed below: + +- database_created: event emitted when the requested database is created. +- endpoints_changed: event emitted when the read/write endpoints of the database have changed. +- read_only_endpoints_changed: event emitted when the read-only endpoints of the database + have changed. Event is not triggered if read/write endpoints changed too. + +If it is needed to connect multiple database clusters to the same relation endpoint +the application charm can implement the same code as if it would connect to only +one database cluster (like the above code example). + +To differentiate multiple clusters connected to the same relation endpoint +the application charm can use the name of the remote application: + +```python + +def _on_database_created(self, event: DatabaseCreatedEvent) -> None: + # Get the remote app name of the cluster that triggered this event + cluster = event.relation.app.name +``` + +It is also possible to provide an alias for each different database cluster/relation. + +So, it is possible to differentiate the clusters in two ways. +The first is to use the remote application name, i.e., `event.relation.app.name`, as above. + +The second way is to use different event handlers to handle each cluster events. +The implementation would be something like the following code: + +```python + +from charms.data_platform_libs.v0.data_interfaces import ( + DatabaseCreatedEvent, + DatabaseRequires, +) + +class ApplicationCharm(CharmBase): + # Application charm that connects to database charms. + + def __init__(self, *args): + super().__init__(*args) + + # Define the cluster aliases and one handler for each cluster database created event. + self.database = DatabaseRequires( + self, + relation_name="database", + database_name="database", + relations_aliases = ["cluster1", "cluster2"], + ) + self.framework.observe( + self.database.on.cluster1_database_created, self._on_cluster1_database_created + ) + self.framework.observe( + self.database.on.cluster2_database_created, self._on_cluster2_database_created + ) + + def _on_cluster1_database_created(self, event: DatabaseCreatedEvent) -> None: + # Handle the created database on the cluster named cluster1 + + # Create configuration file for app + config_file = self._render_app_config_file( + event.username, + event.password, + event.endpoints, + ) + ... + + def _on_cluster2_database_created(self, event: DatabaseCreatedEvent) -> None: + # Handle the created database on the cluster named cluster2 + + # Create configuration file for app + config_file = self._render_app_config_file( + event.username, + event.password, + event.endpoints, + ) + ... + +``` + +When it's needed to check whether a plugin (extension) is enabled on the PostgreSQL +charm, you can use the is_postgresql_plugin_enabled method. To use that, you need to +add the following dependency to your charmcraft.yaml file: + +```yaml + +parts: + charm: + charm-binary-python-packages: + - psycopg[binary] + +``` + +### Provider Charm + +Following an example of using the DatabaseRequestedEvent, in the context of the +database charm code: + +```python +from charms.data_platform_libs.v0.data_interfaces import DatabaseProvides + +class SampleCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + # Charm events defined in the database provides charm library. + self.provided_database = DatabaseProvides(self, relation_name="database") + self.framework.observe(self.provided_database.on.database_requested, + self._on_database_requested) + # Database generic helper + self.database = DatabaseHelper() + + def _on_database_requested(self, event: DatabaseRequestedEvent) -> None: + # Handle the event triggered by a new database requested in the relation + # Retrieve the database name using the charm library. + db_name = event.database + # generate a new user credential + username = self.database.generate_user() + password = self.database.generate_password() + # set the credentials for the relation + self.provided_database.set_credentials(event.relation.id, username, password) + # set other variables for the relation event.set_tls("False") +``` +As shown above, the library provides a custom event (database_requested) to handle +the situation when an application charm requests a new database to be created. +It's preferred to subscribe to this event instead of relation changed event to avoid +creating a new database when other information other than a database name is +exchanged in the relation databag. + +### Kafka + +This library is the interface to use and interact with the Kafka charm. This library contains +custom events that add convenience to manage Kafka, and provides methods to consume the +application related data. + +#### Requirer Charm + +```python + +from charms.data_platform_libs.v0.data_interfaces import ( + BootstrapServerChangedEvent, + KafkaRequires, + TopicCreatedEvent, +) + +class ApplicationCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.kafka = KafkaRequires(self, "kafka_client", "test-topic") + self.framework.observe( + self.kafka.on.bootstrap_server_changed, self._on_kafka_bootstrap_server_changed + ) + self.framework.observe( + self.kafka.on.topic_created, self._on_kafka_topic_created + ) + + def _on_kafka_bootstrap_server_changed(self, event: BootstrapServerChangedEvent): + # Event triggered when a bootstrap server was changed for this application + + new_bootstrap_server = event.bootstrap_server + ... + + def _on_kafka_topic_created(self, event: TopicCreatedEvent): + # Event triggered when a topic was created for this application + username = event.username + password = event.password + tls = event.tls + tls_ca= event.tls_ca + bootstrap_server event.bootstrap_server + consumer_group_prefic = event.consumer_group_prefix + zookeeper_uris = event.zookeeper_uris + ... + +``` + +As shown above, the library provides some custom events to handle specific situations, +which are listed below: + +- topic_created: event emitted when the requested topic is created. +- bootstrap_server_changed: event emitted when the bootstrap server have changed. +- credential_changed: event emitted when the credentials of Kafka changed. + +### Provider Charm + +Following the previous example, this is an example of the provider charm. + +```python +class SampleCharm(CharmBase): + +from charms.data_platform_libs.v0.data_interfaces import ( + KafkaProvides, + TopicRequestedEvent, +) + + def __init__(self, *args): + super().__init__(*args) + + # Default charm events. + self.framework.observe(self.on.start, self._on_start) + + # Charm events defined in the Kafka Provides charm library. + self.kafka_provider = KafkaProvides(self, relation_name="kafka_client") + self.framework.observe(self.kafka_provider.on.topic_requested, self._on_topic_requested) + # Kafka generic helper + self.kafka = KafkaHelper() + + def _on_topic_requested(self, event: TopicRequestedEvent): + # Handle the on_topic_requested event. + + topic = event.topic + relation_id = event.relation.id + # set connection info in the databag relation + self.kafka_provider.set_bootstrap_server(relation_id, self.kafka.get_bootstrap_server()) + self.kafka_provider.set_credentials(relation_id, username=username, password=password) + self.kafka_provider.set_consumer_group_prefix(relation_id, ...) + self.kafka_provider.set_tls(relation_id, "False") + self.kafka_provider.set_zookeeper_uris(relation_id, ...) + +``` +As shown above, the library provides a custom event (topic_requested) to handle +the situation when an application charm requests a new topic to be created. +It is preferred to subscribe to this event instead of relation changed event to avoid +creating a new topic when other information other than a topic name is +exchanged in the relation databag. +""" + +import copy +import json +import logging +from abc import ABC, abstractmethod +from collections import UserDict, namedtuple +from datetime import datetime +from enum import Enum +from typing import ( + Callable, + Dict, + ItemsView, + KeysView, + List, + Optional, + Set, + Tuple, + Union, + ValuesView, +) + +from ops import JujuVersion, Model, Secret, SecretInfo, SecretNotFoundError +from ops.charm import ( + CharmBase, + CharmEvents, + RelationChangedEvent, + RelationCreatedEvent, + RelationEvent, + SecretChangedEvent, +) +from ops.framework import EventSource, Object +from ops.model import Application, ModelError, Relation, Unit + +# The unique Charmhub library identifier, never change it +LIBID = "6c3e6b6680d64e9c89e611d1a15f65be" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 40 + +PYDEPS = ["ops>=2.0.0"] + +# Starting from what LIBPATCH number to apply legacy solutions +# v0.17 was the last version without secrets +LEGACY_SUPPORT_FROM = 17 + +logger = logging.getLogger(__name__) + +Diff = namedtuple("Diff", "added changed deleted") +Diff.__doc__ = """ +A tuple for storing the diff between two data mappings. + +added - keys that were added +changed - keys that still exist but have new values +deleted - key that were deleted""" + + +PROV_SECRET_PREFIX = "secret-" +REQ_SECRET_FIELDS = "requested-secrets" +GROUP_MAPPING_FIELD = "secret_group_mapping" +GROUP_SEPARATOR = "@" + +MODEL_ERRORS = { + "not_leader": "this unit is not the leader", + "no_label_and_uri": "ERROR either URI or label should be used for getting an owned secret but not both", + "owner_no_refresh": "ERROR secret owner cannot use --refresh", +} + + +############################################################################## +# Exceptions +############################################################################## + + +class DataInterfacesError(Exception): + """Common ancestor for DataInterfaces related exceptions.""" + + +class SecretError(DataInterfacesError): + """Common ancestor for Secrets related exceptions.""" + + +class SecretAlreadyExistsError(SecretError): + """A secret that was to be added already exists.""" + + +class SecretsUnavailableError(SecretError): + """Secrets aren't yet available for Juju version used.""" + + +class SecretsIllegalUpdateError(SecretError): + """Secrets aren't yet available for Juju version used.""" + + +class IllegalOperationError(DataInterfacesError): + """To be used when an operation is not allowed to be performed.""" + + +class PrematureDataAccessError(DataInterfacesError): + """To be raised when the Relation Data may be accessed (written) before protocol init complete.""" + + +############################################################################## +# Global helpers / utilities +############################################################################## + +############################################################################## +# Databag handling and comparison methods +############################################################################## + + +def get_encoded_dict( + relation: Relation, member: Union[Unit, Application], field: str +) -> Optional[Dict[str, str]]: + """Retrieve and decode an encoded field from relation data.""" + data = json.loads(relation.data[member].get(field, "{}")) + if isinstance(data, dict): + return data + logger.error("Unexpected datatype for %s instead of dict.", str(data)) + + +def get_encoded_list( + relation: Relation, member: Union[Unit, Application], field: str +) -> Optional[List[str]]: + """Retrieve and decode an encoded field from relation data.""" + data = json.loads(relation.data[member].get(field, "[]")) + if isinstance(data, list): + return data + logger.error("Unexpected datatype for %s instead of list.", str(data)) + + +def set_encoded_field( + relation: Relation, + member: Union[Unit, Application], + field: str, + value: Union[str, list, Dict[str, str]], +) -> None: + """Set an encoded field from relation data.""" + relation.data[member].update({field: json.dumps(value)}) + + +def diff(event: RelationChangedEvent, bucket: Optional[Union[Unit, Application]]) -> Diff: + """Retrieves the diff of the data in the relation changed databag. + + Args: + event: relation changed event. + bucket: bucket of the databag (app or unit) + + Returns: + a Diff instance containing the added, deleted and changed + keys from the event relation databag. + """ + # Retrieve the old data from the data key in the application relation databag. + if not bucket: + return Diff([], [], []) + + old_data = get_encoded_dict(event.relation, bucket, "data") + + if not old_data: + old_data = {} + + # Retrieve the new data from the event relation databag. + new_data = ( + {key: value for key, value in event.relation.data[event.app].items() if key != "data"} + if event.app + else {} + ) + + # These are the keys that were added to the databag and triggered this event. + added = new_data.keys() - old_data.keys() # pyright: ignore [reportAssignmentType] + # These are the keys that were removed from the databag and triggered this event. + deleted = old_data.keys() - new_data.keys() # pyright: ignore [reportAssignmentType] + # These are the keys that already existed in the databag, + # but had their values changed. + changed = { + key + for key in old_data.keys() & new_data.keys() # pyright: ignore [reportAssignmentType] + if old_data[key] != new_data[key] # pyright: ignore [reportAssignmentType] + } + # Convert the new_data to a serializable format and save it for a next diff check. + set_encoded_field(event.relation, bucket, "data", new_data) + + # Return the diff with all possible changes. + return Diff(added, changed, deleted) + + +############################################################################## +# Module decorators +############################################################################## + + +def leader_only(f): + """Decorator to ensure that only leader can perform given operation.""" + + def wrapper(self, *args, **kwargs): + if self.component == self.local_app and not self.local_unit.is_leader(): + logger.error( + "This operation (%s()) can only be performed by the leader unit", f.__name__ + ) + return + return f(self, *args, **kwargs) + + wrapper.leader_only = True + return wrapper + + +def juju_secrets_only(f): + """Decorator to ensure that certain operations would be only executed on Juju3.""" + + def wrapper(self, *args, **kwargs): + if not self.secrets_enabled: + raise SecretsUnavailableError("Secrets unavailable on current Juju version") + return f(self, *args, **kwargs) + + return wrapper + + +def dynamic_secrets_only(f): + """Decorator to ensure that certain operations would be only executed when NO static secrets are defined.""" + + def wrapper(self, *args, **kwargs): + if self.static_secret_fields: + raise IllegalOperationError( + "Unsafe usage of statically and dynamically defined secrets, aborting." + ) + return f(self, *args, **kwargs) + + return wrapper + + +def either_static_or_dynamic_secrets(f): + """Decorator to ensure that static and dynamic secrets won't be used in parallel.""" + + def wrapper(self, *args, **kwargs): + if self.static_secret_fields and set(self.current_secret_fields) - set( + self.static_secret_fields + ): + raise IllegalOperationError( + "Unsafe usage of statically and dynamically defined secrets, aborting." + ) + return f(self, *args, **kwargs) + + return wrapper + + +def legacy_apply_from_version(version: int) -> Callable: + """Decorator to decide whether to apply a legacy function or not. + + Based on LEGACY_SUPPORT_FROM module variable value, the importer charm may only want + to apply legacy solutions starting from a specific LIBPATCH. + + NOTE: All 'legacy' functions have to be defined and called in a way that they return `None`. + This results in cleaner and more secure execution flows in case the function may be disabled. + This requirement implicitly means that legacy functions change the internal state strictly, + don't return information. + """ + + def decorator(f: Callable[..., None]): + """Signature is ensuring None return value.""" + f.legacy_version = version + + def wrapper(self, *args, **kwargs) -> None: + if version >= LEGACY_SUPPORT_FROM: + return f(self, *args, **kwargs) + + return wrapper + + return decorator + + +############################################################################## +# Helper classes +############################################################################## + + +class Scope(Enum): + """Peer relations scope.""" + + APP = "app" + UNIT = "unit" + + +class SecretGroup(str): + """Secret groups specific type.""" + + +class SecretGroupsAggregate(str): + """Secret groups with option to extend with additional constants.""" + + def __init__(self): + self.USER = SecretGroup("user") + self.TLS = SecretGroup("tls") + self.EXTRA = SecretGroup("extra") + + def __setattr__(self, name, value): + """Setting internal constants.""" + if name in self.__dict__: + raise RuntimeError("Can't set constant!") + else: + super().__setattr__(name, SecretGroup(value)) + + def groups(self) -> list: + """Return the list of stored SecretGroups.""" + return list(self.__dict__.values()) + + def get_group(self, group: str) -> Optional[SecretGroup]: + """If the input str translates to a group name, return that.""" + return SecretGroup(group) if group in self.groups() else None + + +SECRET_GROUPS = SecretGroupsAggregate() + + +class CachedSecret: + """Locally cache a secret. + + The data structure is precisely re-using/simulating as in the actual Secret Storage + """ + + KNOWN_MODEL_ERRORS = [MODEL_ERRORS["no_label_and_uri"], MODEL_ERRORS["owner_no_refresh"]] + + def __init__( + self, + model: Model, + component: Union[Application, Unit], + label: str, + secret_uri: Optional[str] = None, + legacy_labels: List[str] = [], + ): + self._secret_meta = None + self._secret_content = {} + self._secret_uri = secret_uri + self.label = label + self._model = model + self.component = component + self.legacy_labels = legacy_labels + self.current_label = None + + @property + def meta(self) -> Optional[Secret]: + """Getting cached secret meta-information.""" + if not self._secret_meta: + if not (self._secret_uri or self.label): + return + + try: + self._secret_meta = self._model.get_secret(label=self.label) + except SecretNotFoundError: + # Falling back to seeking for potential legacy labels + self._legacy_compat_find_secret_by_old_label() + + # If still not found, to be checked by URI, to be labelled with the proposed label + if not self._secret_meta and self._secret_uri: + self._secret_meta = self._model.get_secret(id=self._secret_uri, label=self.label) + return self._secret_meta + + ########################################################################## + # Backwards compatibility / Upgrades + ########################################################################## + # These functions are used to keep backwards compatibility on rolling upgrades + # Policy: + # All data is kept intact until the first write operation. (This allows a minimal + # grace period during which rollbacks are fully safe. For more info see the spec.) + # All data involves: + # - databag contents + # - secrets content + # - secret labels (!!!) + # Legacy functions must return None, and leave an equally consistent state whether + # they are executed or skipped (as a high enough versioned execution environment may + # not require so) + + # Compatibility + + @legacy_apply_from_version(34) + def _legacy_compat_find_secret_by_old_label(self) -> None: + """Compatibility function, allowing to find a secret by a legacy label. + + This functionality is typically needed when secret labels changed over an upgrade. + Until the first write operation, we need to maintain data as it was, including keeping + the old secret label. In order to keep track of the old label currently used to access + the secret, and additional 'current_label' field is being defined. + """ + for label in self.legacy_labels: + try: + self._secret_meta = self._model.get_secret(label=label) + except SecretNotFoundError: + pass + else: + if label != self.label: + self.current_label = label + return + + # Migrations + + @legacy_apply_from_version(34) + def _legacy_migration_to_new_label_if_needed(self) -> None: + """Helper function to re-create the secret with a different label. + + Juju does not provide a way to change secret labels. + Thus whenever moving from secrets version that involves secret label changes, + we "re-create" the existing secret, and attach the new label to the new + secret, to be used from then on. + + Note: we replace the old secret with a new one "in place", as we can't + easily switch the containing SecretCache structure to point to a new secret. + Instead we are changing the 'self' (CachedSecret) object to point to the + new instance. + """ + if not self.current_label or not (self.meta and self._secret_meta): + return + + # Create a new secret with the new label + content = self._secret_meta.get_content() + self._secret_uri = None + + # It will be nice to have the possibility to check if we are the owners of the secret... + try: + self._secret_meta = self.add_secret(content, label=self.label) + except ModelError as err: + if MODEL_ERRORS["not_leader"] not in str(err): + raise + self.current_label = None + + ########################################################################## + # Public functions + ########################################################################## + + def add_secret( + self, + content: Dict[str, str], + relation: Optional[Relation] = None, + label: Optional[str] = None, + ) -> Secret: + """Create a new secret.""" + if self._secret_uri: + raise SecretAlreadyExistsError( + "Secret is already defined with uri %s", self._secret_uri + ) + + label = self.label if not label else label + + secret = self.component.add_secret(content, label=label) + if relation and relation.app != self._model.app: + # If it's not a peer relation, grant is to be applied + secret.grant(relation) + self._secret_uri = secret.id + self._secret_meta = secret + return self._secret_meta + + def get_content(self) -> Dict[str, str]: + """Getting cached secret content.""" + if not self._secret_content: + if self.meta: + try: + self._secret_content = self.meta.get_content(refresh=True) + except (ValueError, ModelError) as err: + # https://bugs.launchpad.net/juju/+bug/2042596 + # Only triggered when 'refresh' is set + if isinstance(err, ModelError) and not any( + msg in str(err) for msg in self.KNOWN_MODEL_ERRORS + ): + raise + # Due to: ValueError: Secret owner cannot use refresh=True + self._secret_content = self.meta.get_content() + return self._secret_content + + def set_content(self, content: Dict[str, str]) -> None: + """Setting cached secret content.""" + if not self.meta: + return + + # DPE-4182: do not create new revision if the content stay the same + if content == self.get_content(): + return + + if content: + self._legacy_migration_to_new_label_if_needed() + self.meta.set_content(content) + self._secret_content = content + else: + self.meta.remove_all_revisions() + + def get_info(self) -> Optional[SecretInfo]: + """Wrapper function to apply the corresponding call on the Secret object within CachedSecret if any.""" + if self.meta: + return self.meta.get_info() + + def remove(self) -> None: + """Remove secret.""" + if not self.meta: + raise SecretsUnavailableError("Non-existent secret was attempted to be removed.") + try: + self.meta.remove_all_revisions() + except SecretNotFoundError: + pass + self._secret_content = {} + self._secret_meta = None + self._secret_uri = None + + +class SecretCache: + """A data structure storing CachedSecret objects.""" + + def __init__(self, model: Model, component: Union[Application, Unit]): + self._model = model + self.component = component + self._secrets: Dict[str, CachedSecret] = {} + + def get( + self, label: str, uri: Optional[str] = None, legacy_labels: List[str] = [] + ) -> Optional[CachedSecret]: + """Getting a secret from Juju Secret store or cache.""" + if not self._secrets.get(label): + secret = CachedSecret( + self._model, self.component, label, uri, legacy_labels=legacy_labels + ) + if secret.meta: + self._secrets[label] = secret + return self._secrets.get(label) + + def add(self, label: str, content: Dict[str, str], relation: Relation) -> CachedSecret: + """Adding a secret to Juju Secret.""" + if self._secrets.get(label): + raise SecretAlreadyExistsError(f"Secret {label} already exists") + + secret = CachedSecret(self._model, self.component, label) + secret.add_secret(content, relation) + self._secrets[label] = secret + return self._secrets[label] + + def remove(self, label: str) -> None: + """Remove a secret from the cache.""" + if secret := self.get(label): + try: + secret.remove() + self._secrets.pop(label) + except (SecretsUnavailableError, KeyError): + pass + else: + return + logging.debug("Non-existing Juju Secret was attempted to be removed %s", label) + + +################################################################################ +# Relation Data base/abstract ancestors (i.e. parent classes) +################################################################################ + + +# Base Data + + +class DataDict(UserDict): + """Python Standard Library 'dict' - like representation of Relation Data.""" + + def __init__(self, relation_data: "Data", relation_id: int): + self.relation_data = relation_data + self.relation_id = relation_id + + @property + def data(self) -> Dict[str, str]: + """Return the full content of the Abstract Relation Data dictionary.""" + result = self.relation_data.fetch_my_relation_data([self.relation_id]) + try: + result_remote = self.relation_data.fetch_relation_data([self.relation_id]) + except NotImplementedError: + result_remote = {self.relation_id: {}} + if result: + result_remote[self.relation_id].update(result[self.relation_id]) + return result_remote.get(self.relation_id, {}) + + def __setitem__(self, key: str, item: str) -> None: + """Set an item of the Abstract Relation Data dictionary.""" + self.relation_data.update_relation_data(self.relation_id, {key: item}) + + def __getitem__(self, key: str) -> str: + """Get an item of the Abstract Relation Data dictionary.""" + result = None + + # Avoiding "leader_only" error when cross-charm non-leader unit, not to report useless error + if ( + not hasattr(self.relation_data.fetch_my_relation_field, "leader_only") + or self.relation_data.component != self.relation_data.local_app + or self.relation_data.local_unit.is_leader() + ): + result = self.relation_data.fetch_my_relation_field(self.relation_id, key) + + if not result: + try: + result = self.relation_data.fetch_relation_field(self.relation_id, key) + except NotImplementedError: + pass + + if not result: + raise KeyError + return result + + def __eq__(self, d: dict) -> bool: + """Equality.""" + return self.data == d + + def __repr__(self) -> str: + """String representation Abstract Relation Data dictionary.""" + return repr(self.data) + + def __len__(self) -> int: + """Length of the Abstract Relation Data dictionary.""" + return len(self.data) + + def __delitem__(self, key: str) -> None: + """Delete an item of the Abstract Relation Data dictionary.""" + self.relation_data.delete_relation_data(self.relation_id, [key]) + + def has_key(self, key: str) -> bool: + """Does the key exist in the Abstract Relation Data dictionary?""" + return key in self.data + + def update(self, items: Dict[str, str]): + """Update the Abstract Relation Data dictionary.""" + self.relation_data.update_relation_data(self.relation_id, items) + + def keys(self) -> KeysView[str]: + """Keys of the Abstract Relation Data dictionary.""" + return self.data.keys() + + def values(self) -> ValuesView[str]: + """Values of the Abstract Relation Data dictionary.""" + return self.data.values() + + def items(self) -> ItemsView[str, str]: + """Items of the Abstract Relation Data dictionary.""" + return self.data.items() + + def pop(self, item: str) -> str: + """Pop an item of the Abstract Relation Data dictionary.""" + result = self.relation_data.fetch_my_relation_field(self.relation_id, item) + if not result: + raise KeyError(f"Item {item} doesn't exist.") + self.relation_data.delete_relation_data(self.relation_id, [item]) + return result + + def __contains__(self, item: str) -> bool: + """Does the Abstract Relation Data dictionary contain item?""" + return item in self.data.values() + + def __iter__(self): + """Iterate through the Abstract Relation Data dictionary.""" + return iter(self.data) + + def get(self, key: str, default: Optional[str] = None) -> Optional[str]: + """Safely get an item of the Abstract Relation Data dictionary.""" + try: + if result := self[key]: + return result + except KeyError: + return default + + +class Data(ABC): + """Base relation data mainpulation (abstract) class.""" + + SCOPE = Scope.APP + + # Local map to associate mappings with secrets potentially as a group + SECRET_LABEL_MAP = { + "username": SECRET_GROUPS.USER, + "password": SECRET_GROUPS.USER, + "uris": SECRET_GROUPS.USER, + "tls": SECRET_GROUPS.TLS, + "tls-ca": SECRET_GROUPS.TLS, + } + + def __init__( + self, + model: Model, + relation_name: str, + ) -> None: + self._model = model + self.local_app = self._model.app + self.local_unit = self._model.unit + self.relation_name = relation_name + self._jujuversion = None + self.component = self.local_app if self.SCOPE == Scope.APP else self.local_unit + self.secrets = SecretCache(self._model, self.component) + self.data_component = None + + @property + def relations(self) -> List[Relation]: + """The list of Relation instances associated with this relation_name.""" + return [ + relation + for relation in self._model.relations[self.relation_name] + if self._is_relation_active(relation) + ] + + @property + def secrets_enabled(self): + """Is this Juju version allowing for Secrets usage?""" + if not self._jujuversion: + self._jujuversion = JujuVersion.from_environ() + return self._jujuversion.has_secrets + + @property + def secret_label_map(self): + """Exposing secret-label map via a property -- could be overridden in descendants!""" + return self.SECRET_LABEL_MAP + + # Mandatory overrides for internal/helper methods + + @abstractmethod + def _get_relation_secret( + self, relation_id: int, group_mapping: SecretGroup, relation_name: Optional[str] = None + ) -> Optional[CachedSecret]: + """Retrieve a Juju Secret that's been stored in the relation databag.""" + raise NotImplementedError + + @abstractmethod + def _fetch_specific_relation_data( + self, relation: Relation, fields: Optional[List[str]] + ) -> Dict[str, str]: + """Fetch data available (directily or indirectly -- i.e. secrets) from the relation.""" + raise NotImplementedError + + @abstractmethod + def _fetch_my_specific_relation_data( + self, relation: Relation, fields: Optional[List[str]] + ) -> Dict[str, str]: + """Fetch data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" + raise NotImplementedError + + @abstractmethod + def _update_relation_data(self, relation: Relation, data: Dict[str, str]) -> None: + """Update data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" + raise NotImplementedError + + @abstractmethod + def _delete_relation_data(self, relation: Relation, fields: List[str]) -> None: + """Delete data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" + raise NotImplementedError + + # Optional overrides + + def _legacy_apply_on_fetch(self) -> None: + """This function should provide a list of compatibility functions to be applied when fetching (legacy) data.""" + pass + + def _legacy_apply_on_update(self, fields: List[str]) -> None: + """This function should provide a list of compatibility functions to be applied when writing data. + + Since data may be at a legacy version, migration may be mandatory. + """ + pass + + def _legacy_apply_on_delete(self, fields: List[str]) -> None: + """This function should provide a list of compatibility functions to be applied when deleting (legacy) data.""" + pass + + # Internal helper methods + + @staticmethod + def _is_relation_active(relation: Relation): + """Whether the relation is active based on contained data.""" + try: + _ = repr(relation.data) + return True + except (RuntimeError, ModelError): + return False + + @staticmethod + def _is_secret_field(field: str) -> bool: + """Is the field in question a secret reference (URI) field or not?""" + return field.startswith(PROV_SECRET_PREFIX) + + @staticmethod + def _generate_secret_label( + relation_name: str, relation_id: int, group_mapping: SecretGroup + ) -> str: + """Generate unique group_mappings for secrets within a relation context.""" + return f"{relation_name}.{relation_id}.{group_mapping}.secret" + + def _generate_secret_field_name(self, group_mapping: SecretGroup) -> str: + """Generate unique group_mappings for secrets within a relation context.""" + return f"{PROV_SECRET_PREFIX}{group_mapping}" + + def _relation_from_secret_label(self, secret_label: str) -> Optional[Relation]: + """Retrieve the relation that belongs to a secret label.""" + contents = secret_label.split(".") + + if not (contents and len(contents) >= 3): + return + + contents.pop() # ".secret" at the end + contents.pop() # Group mapping + relation_id = contents.pop() + try: + relation_id = int(relation_id) + except ValueError: + return + + # In case '.' character appeared in relation name + relation_name = ".".join(contents) + + try: + return self.get_relation(relation_name, relation_id) + except ModelError: + return + + def _group_secret_fields(self, secret_fields: List[str]) -> Dict[SecretGroup, List[str]]: + """Helper function to arrange secret mappings under their group. + + NOTE: All unrecognized items end up in the 'extra' secret bucket. + Make sure only secret fields are passed! + """ + secret_fieldnames_grouped = {} + for key in secret_fields: + if group := self.secret_label_map.get(key): + secret_fieldnames_grouped.setdefault(group, []).append(key) + else: + secret_fieldnames_grouped.setdefault(SECRET_GROUPS.EXTRA, []).append(key) + return secret_fieldnames_grouped + + def _get_group_secret_contents( + self, + relation: Relation, + group: SecretGroup, + secret_fields: Union[Set[str], List[str]] = [], + ) -> Dict[str, str]: + """Helper function to retrieve collective, requested contents of a secret.""" + if (secret := self._get_relation_secret(relation.id, group)) and ( + secret_data := secret.get_content() + ): + return { + k: v for k, v in secret_data.items() if not secret_fields or k in secret_fields + } + return {} + + def _content_for_secret_group( + self, content: Dict[str, str], secret_fields: Set[str], group_mapping: SecretGroup + ) -> Dict[str, str]: + """Select : pairs from input, that belong to this particular Secret group.""" + if group_mapping == SECRET_GROUPS.EXTRA: + return { + k: v + for k, v in content.items() + if k in secret_fields and k not in self.secret_label_map.keys() + } + + return { + k: v + for k, v in content.items() + if k in secret_fields and self.secret_label_map.get(k) == group_mapping + } + + @juju_secrets_only + def _get_relation_secret_data( + self, relation_id: int, group_mapping: SecretGroup, relation_name: Optional[str] = None + ) -> Optional[Dict[str, str]]: + """Retrieve contents of a Juju Secret that's been stored in the relation databag.""" + secret = self._get_relation_secret(relation_id, group_mapping, relation_name) + if secret: + return secret.get_content() + + # Core operations on Relation Fields manipulations (regardless whether the field is in the databag or in a secret) + # Internal functions to be called directly from transparent public interface functions (+closely related helpers) + + def _process_secret_fields( + self, + relation: Relation, + req_secret_fields: Optional[List[str]], + impacted_rel_fields: List[str], + operation: Callable, + *args, + **kwargs, + ) -> Tuple[Dict[str, str], Set[str]]: + """Isolate target secret fields of manipulation, and execute requested operation by Secret Group.""" + result = {} + + # If the relation started on a databag, we just stay on the databag + # (Rolling upgrades may result in a relation starting on databag, getting secrets enabled on-the-fly) + # self.local_app is sufficient to check (ignored if Requires, never has secrets -- works if Provider) + fallback_to_databag = ( + req_secret_fields + and (self.local_unit == self._model.unit and self.local_unit.is_leader()) + and set(req_secret_fields) & set(relation.data[self.component]) + ) + + normal_fields = set(impacted_rel_fields) + if req_secret_fields and self.secrets_enabled and not fallback_to_databag: + normal_fields = normal_fields - set(req_secret_fields) + secret_fields = set(impacted_rel_fields) - set(normal_fields) + + secret_fieldnames_grouped = self._group_secret_fields(list(secret_fields)) + + for group in secret_fieldnames_grouped: + # operation() should return nothing when all goes well + if group_result := operation(relation, group, secret_fields, *args, **kwargs): + # If "meaningful" data was returned, we take it. (Some 'operation'-s only return success/failure.) + if isinstance(group_result, dict): + result.update(group_result) + else: + # If it wasn't found as a secret, let's give it a 2nd chance as "normal" field + # Needed when Juju3 Requires meets Juju2 Provider + normal_fields |= set(secret_fieldnames_grouped[group]) + return (result, normal_fields) + + def _fetch_relation_data_without_secrets( + self, component: Union[Application, Unit], relation: Relation, fields: Optional[List[str]] + ) -> Dict[str, str]: + """Fetching databag contents when no secrets are involved. + + Since the Provider's databag is the only one holding secrest, we can apply + a simplified workflow to read the Require's side's databag. + This is used typically when the Provider side wants to read the Requires side's data, + or when the Requires side may want to read its own data. + """ + if component not in relation.data or not relation.data[component]: + return {} + + if fields: + return { + k: relation.data[component][k] for k in fields if k in relation.data[component] + } + else: + return dict(relation.data[component]) + + def _fetch_relation_data_with_secrets( + self, + component: Union[Application, Unit], + req_secret_fields: Optional[List[str]], + relation: Relation, + fields: Optional[List[str]] = None, + ) -> Dict[str, str]: + """Fetching databag contents when secrets may be involved. + + This function has internal logic to resolve if a requested field may be "hidden" + within a Relation Secret, or directly available as a databag field. Typically + used to read the Provider side's databag (eigher by the Requires side, or by + Provider side itself). + """ + result = {} + normal_fields = [] + + if not fields: + if component not in relation.data: + return {} + + all_fields = list(relation.data[component].keys()) + normal_fields = [field for field in all_fields if not self._is_secret_field(field)] + fields = normal_fields + req_secret_fields if req_secret_fields else normal_fields + + if fields: + result, normal_fields = self._process_secret_fields( + relation, req_secret_fields, fields, self._get_group_secret_contents + ) + + # Processing "normal" fields. May include leftover from what we couldn't retrieve as a secret. + # (Typically when Juju3 Requires meets Juju2 Provider) + if normal_fields: + result.update( + self._fetch_relation_data_without_secrets(component, relation, list(normal_fields)) + ) + return result + + def _update_relation_data_without_secrets( + self, component: Union[Application, Unit], relation: Relation, data: Dict[str, str] + ) -> None: + """Updating databag contents when no secrets are involved.""" + if component not in relation.data or relation.data[component] is None: + return + + if relation: + relation.data[component].update(data) + + def _delete_relation_data_without_secrets( + self, component: Union[Application, Unit], relation: Relation, fields: List[str] + ) -> None: + """Remove databag fields 'fields' from Relation.""" + if component not in relation.data or relation.data[component] is None: + return + + for field in fields: + try: + relation.data[component].pop(field) + except KeyError: + logger.debug( + "Non-existing field '%s' was attempted to be removed from the databag (relation ID: %s)", + str(field), + str(relation.id), + ) + pass + + # Public interface methods + # Handling Relation Fields seamlessly, regardless if in databag or a Juju Secret + + def as_dict(self, relation_id: int) -> UserDict: + """Dict behavior representation of the Abstract Data.""" + return DataDict(self, relation_id) + + def get_relation(self, relation_name, relation_id) -> Relation: + """Safe way of retrieving a relation.""" + relation = self._model.get_relation(relation_name, relation_id) + + if not relation: + raise DataInterfacesError( + "Relation %s %s couldn't be retrieved", relation_name, relation_id + ) + + return relation + + def get_secret_uri(self, relation: Relation, group: SecretGroup) -> Optional[str]: + """Get the secret URI for the corresponding group.""" + secret_field = self._generate_secret_field_name(group) + return relation.data[self.component].get(secret_field) + + def set_secret_uri(self, relation: Relation, group: SecretGroup, secret_uri: str) -> None: + """Set the secret URI for the corresponding group.""" + secret_field = self._generate_secret_field_name(group) + relation.data[self.component][secret_field] = secret_uri + + def fetch_relation_data( + self, + relation_ids: Optional[List[int]] = None, + fields: Optional[List[str]] = None, + relation_name: Optional[str] = None, + ) -> Dict[int, Dict[str, str]]: + """Retrieves data from relation. + + This function can be used to retrieve data from a relation + in the charm code when outside an event callback. + Function cannot be used in `*-relation-broken` events and will raise an exception. + + Returns: + a dict of the values stored in the relation data bag + for all relation instances (indexed by the relation ID). + """ + self._legacy_apply_on_fetch() + + if not relation_name: + relation_name = self.relation_name + + relations = [] + if relation_ids: + relations = [ + self.get_relation(relation_name, relation_id) for relation_id in relation_ids + ] + else: + relations = self.relations + + data = {} + for relation in relations: + if not relation_ids or (relation_ids and relation.id in relation_ids): + data[relation.id] = self._fetch_specific_relation_data(relation, fields) + return data + + def fetch_relation_field( + self, relation_id: int, field: str, relation_name: Optional[str] = None + ) -> Optional[str]: + """Get a single field from the relation data.""" + return ( + self.fetch_relation_data([relation_id], [field], relation_name) + .get(relation_id, {}) + .get(field) + ) + + def fetch_my_relation_data( + self, + relation_ids: Optional[List[int]] = None, + fields: Optional[List[str]] = None, + relation_name: Optional[str] = None, + ) -> Optional[Dict[int, Dict[str, str]]]: + """Fetch data of the 'owner' (or 'this app') side of the relation. + + NOTE: Since only the leader can read the relation's 'this_app'-side + Application databag, the functionality is limited to leaders + """ + self._legacy_apply_on_fetch() + + if not relation_name: + relation_name = self.relation_name + + relations = [] + if relation_ids: + relations = [ + self.get_relation(relation_name, relation_id) for relation_id in relation_ids + ] + else: + relations = self.relations + + data = {} + for relation in relations: + if not relation_ids or relation.id in relation_ids: + data[relation.id] = self._fetch_my_specific_relation_data(relation, fields) + return data + + def fetch_my_relation_field( + self, relation_id: int, field: str, relation_name: Optional[str] = None + ) -> Optional[str]: + """Get a single field from the relation data -- owner side. + + NOTE: Since only the leader can read the relation's 'this_app'-side + Application databag, the functionality is limited to leaders + """ + if relation_data := self.fetch_my_relation_data([relation_id], [field], relation_name): + return relation_data.get(relation_id, {}).get(field) + + @leader_only + def update_relation_data(self, relation_id: int, data: dict) -> None: + """Update the data within the relation.""" + self._legacy_apply_on_update(list(data.keys())) + + relation_name = self.relation_name + relation = self.get_relation(relation_name, relation_id) + return self._update_relation_data(relation, data) + + @leader_only + def delete_relation_data(self, relation_id: int, fields: List[str]) -> None: + """Remove field from the relation.""" + self._legacy_apply_on_delete(fields) + + relation_name = self.relation_name + relation = self.get_relation(relation_name, relation_id) + return self._delete_relation_data(relation, fields) + + +class EventHandlers(Object): + """Requires-side of the relation.""" + + def __init__(self, charm: CharmBase, relation_data: Data, unique_key: str = ""): + """Manager of base client relations.""" + if not unique_key: + unique_key = relation_data.relation_name + super().__init__(charm, unique_key) + + self.charm = charm + self.relation_data = relation_data + + self.framework.observe( + charm.on[self.relation_data.relation_name].relation_changed, + self._on_relation_changed_event, + ) + + def _diff(self, event: RelationChangedEvent) -> Diff: + """Retrieves the diff of the data in the relation changed databag. + + Args: + event: relation changed event. + + Returns: + a Diff instance containing the added, deleted and changed + keys from the event relation databag. + """ + return diff(event, self.relation_data.data_component) + + @abstractmethod + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the relation data has changed.""" + raise NotImplementedError + + +# Base ProviderData and RequiresData + + +class ProviderData(Data): + """Base provides-side of the data products relation.""" + + RESOURCE_FIELD = "database" + + def __init__( + self, + model: Model, + relation_name: str, + ) -> None: + super().__init__(model, relation_name) + self.data_component = self.local_app + + # Private methods handling secrets + + @juju_secrets_only + def _add_relation_secret( + self, + relation: Relation, + group_mapping: SecretGroup, + secret_fields: Set[str], + data: Dict[str, str], + uri_to_databag=True, + ) -> bool: + """Add a new Juju Secret that will be registered in the relation databag.""" + if uri_to_databag and self.get_secret_uri(relation, group_mapping): + logging.error("Secret for relation %s already exists, not adding again", relation.id) + return False + + content = self._content_for_secret_group(data, secret_fields, group_mapping) + + label = self._generate_secret_label(self.relation_name, relation.id, group_mapping) + secret = self.secrets.add(label, content, relation) + + # According to lint we may not have a Secret ID + if uri_to_databag and secret.meta and secret.meta.id: + self.set_secret_uri(relation, group_mapping, secret.meta.id) + + # Return the content that was added + return True + + @juju_secrets_only + def _update_relation_secret( + self, + relation: Relation, + group_mapping: SecretGroup, + secret_fields: Set[str], + data: Dict[str, str], + ) -> bool: + """Update the contents of an existing Juju Secret, referred in the relation databag.""" + secret = self._get_relation_secret(relation.id, group_mapping) + + if not secret: + logging.error("Can't update secret for relation %s", relation.id) + return False + + content = self._content_for_secret_group(data, secret_fields, group_mapping) + + old_content = secret.get_content() + full_content = copy.deepcopy(old_content) + full_content.update(content) + secret.set_content(full_content) + + # Return True on success + return True + + def _add_or_update_relation_secrets( + self, + relation: Relation, + group: SecretGroup, + secret_fields: Set[str], + data: Dict[str, str], + uri_to_databag=True, + ) -> bool: + """Update contents for Secret group. If the Secret doesn't exist, create it.""" + if self._get_relation_secret(relation.id, group): + return self._update_relation_secret(relation, group, secret_fields, data) + else: + return self._add_relation_secret(relation, group, secret_fields, data, uri_to_databag) + + @juju_secrets_only + def _delete_relation_secret( + self, relation: Relation, group: SecretGroup, secret_fields: List[str], fields: List[str] + ) -> bool: + """Update the contents of an existing Juju Secret, referred in the relation databag.""" + secret = self._get_relation_secret(relation.id, group) + + if not secret: + logging.error("Can't delete secret for relation %s", str(relation.id)) + return False + + old_content = secret.get_content() + new_content = copy.deepcopy(old_content) + for field in fields: + try: + new_content.pop(field) + except KeyError: + logging.debug( + "Non-existing secret was attempted to be removed %s, %s", + str(relation.id), + str(field), + ) + return False + + # Remove secret from the relation if it's fully gone + if not new_content: + field = self._generate_secret_field_name(group) + try: + relation.data[self.component].pop(field) + except KeyError: + pass + label = self._generate_secret_label(self.relation_name, relation.id, group) + self.secrets.remove(label) + else: + secret.set_content(new_content) + + # Return the content that was removed + return True + + # Mandatory internal overrides + + @juju_secrets_only + def _get_relation_secret( + self, relation_id: int, group_mapping: SecretGroup, relation_name: Optional[str] = None + ) -> Optional[CachedSecret]: + """Retrieve a Juju Secret that's been stored in the relation databag.""" + if not relation_name: + relation_name = self.relation_name + + label = self._generate_secret_label(relation_name, relation_id, group_mapping) + if secret := self.secrets.get(label): + return secret + + relation = self._model.get_relation(relation_name, relation_id) + if not relation: + return + + if secret_uri := self.get_secret_uri(relation, group_mapping): + return self.secrets.get(label, secret_uri) + + def _fetch_specific_relation_data( + self, relation: Relation, fields: Optional[List[str]] + ) -> Dict[str, str]: + """Fetching relation data for Provider. + + NOTE: Since all secret fields are in the Provider side of the databag, we don't need to worry about that + """ + if not relation.app: + return {} + + return self._fetch_relation_data_without_secrets(relation.app, relation, fields) + + def _fetch_my_specific_relation_data( + self, relation: Relation, fields: Optional[List[str]] + ) -> dict: + """Fetching our own relation data.""" + secret_fields = None + if relation.app: + secret_fields = get_encoded_list(relation, relation.app, REQ_SECRET_FIELDS) + + return self._fetch_relation_data_with_secrets( + self.local_app, + secret_fields, + relation, + fields, + ) + + def _update_relation_data(self, relation: Relation, data: Dict[str, str]) -> None: + """Set values for fields not caring whether it's a secret or not.""" + req_secret_fields = [] + + keys = set(data.keys()) + if self.fetch_relation_field(relation.id, self.RESOURCE_FIELD) is None and ( + keys - {"endpoints", "read-only-endpoints", "replset"} + ): + raise PrematureDataAccessError( + "Premature access to relation data, update is forbidden before the connection is initialized." + ) + + if relation.app: + req_secret_fields = get_encoded_list(relation, relation.app, REQ_SECRET_FIELDS) + + _, normal_fields = self._process_secret_fields( + relation, + req_secret_fields, + list(data), + self._add_or_update_relation_secrets, + data=data, + ) + + normal_content = {k: v for k, v in data.items() if k in normal_fields} + self._update_relation_data_without_secrets(self.local_app, relation, normal_content) + + def _delete_relation_data(self, relation: Relation, fields: List[str]) -> None: + """Delete fields from the Relation not caring whether it's a secret or not.""" + req_secret_fields = [] + if relation.app: + req_secret_fields = get_encoded_list(relation, relation.app, REQ_SECRET_FIELDS) + + _, normal_fields = self._process_secret_fields( + relation, req_secret_fields, fields, self._delete_relation_secret, fields=fields + ) + self._delete_relation_data_without_secrets(self.local_app, relation, list(normal_fields)) + + # Public methods - "native" + + def set_credentials(self, relation_id: int, username: str, password: str) -> None: + """Set credentials. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + username: user that was created. + password: password of the created user. + """ + self.update_relation_data(relation_id, {"username": username, "password": password}) + + def set_tls(self, relation_id: int, tls: str) -> None: + """Set whether TLS is enabled. + + Args: + relation_id: the identifier for a particular relation. + tls: whether tls is enabled (True or False). + """ + self.update_relation_data(relation_id, {"tls": tls}) + + def set_tls_ca(self, relation_id: int, tls_ca: str) -> None: + """Set the TLS CA in the application relation databag. + + Args: + relation_id: the identifier for a particular relation. + tls_ca: TLS certification authority. + """ + self.update_relation_data(relation_id, {"tls-ca": tls_ca}) + + # Public functions -- inherited + + fetch_my_relation_data = leader_only(Data.fetch_my_relation_data) + fetch_my_relation_field = leader_only(Data.fetch_my_relation_field) + + +class RequirerData(Data): + """Requirer-side of the relation.""" + + SECRET_FIELDS = ["username", "password", "tls", "tls-ca", "uris"] + + def __init__( + self, + model, + relation_name: str, + extra_user_roles: Optional[str] = None, + additional_secret_fields: Optional[List[str]] = [], + ): + """Manager of base client relations.""" + super().__init__(model, relation_name) + self.extra_user_roles = extra_user_roles + self._secret_fields = list(self.SECRET_FIELDS) + if additional_secret_fields: + self._secret_fields += additional_secret_fields + self.data_component = self.local_unit + + @property + def secret_fields(self) -> Optional[List[str]]: + """Local access to secrets field, in case they are being used.""" + if self.secrets_enabled: + return self._secret_fields + + # Internal helper functions + + def _register_secret_to_relation( + self, relation_name: str, relation_id: int, secret_id: str, group: SecretGroup + ): + """Fetch secrets and apply local label on them. + + [MAGIC HERE] + If we fetch a secret using get_secret(id=, label=), + then will be "stuck" on the Secret object, whenever it may + appear (i.e. as an event attribute, or fetched manually) on future occasions. + + This will allow us to uniquely identify the secret on Provider side (typically on + 'secret-changed' events), and map it to the corresponding relation. + """ + label = self._generate_secret_label(relation_name, relation_id, group) + + # Fetching the Secret's meta information ensuring that it's locally getting registered with + CachedSecret(self._model, self.component, label, secret_id).meta + + def _register_secrets_to_relation(self, relation: Relation, params_name_list: List[str]): + """Make sure that secrets of the provided list are locally 'registered' from the databag. + + More on 'locally registered' magic is described in _register_secret_to_relation() method + """ + if not relation.app: + return + + for group in SECRET_GROUPS.groups(): + secret_field = self._generate_secret_field_name(group) + if secret_field in params_name_list and ( + secret_uri := self.get_secret_uri(relation, group) + ): + self._register_secret_to_relation(relation.name, relation.id, secret_uri, group) + + def _is_resource_created_for_relation(self, relation: Relation) -> bool: + if not relation.app: + return False + + data = self.fetch_relation_data([relation.id], ["username", "password"]).get( + relation.id, {} + ) + return bool(data.get("username")) and bool(data.get("password")) + + # Public functions + + def get_secret_uri(self, relation: Relation, group: SecretGroup) -> Optional[str]: + """Getting relation secret URI for the corresponding Secret Group.""" + secret_field = self._generate_secret_field_name(group) + return relation.data[relation.app].get(secret_field) + + def set_secret_uri(self, relation: Relation, group: SecretGroup, uri: str) -> None: + """Setting relation secret URI is not possible for a Requirer.""" + raise NotImplementedError("Requirer can not change the relation secret URI.") + + def is_resource_created(self, relation_id: Optional[int] = None) -> bool: + """Check if the resource has been created. + + This function can be used to check if the Provider answered with data in the charm code + when outside an event callback. + + Args: + relation_id (int, optional): When provided the check is done only for the relation id + provided, otherwise the check is done for all relations + + Returns: + True or False + + Raises: + IndexError: If relation_id is provided but that relation does not exist + """ + if relation_id is not None: + try: + relation = [relation for relation in self.relations if relation.id == relation_id][ + 0 + ] + return self._is_resource_created_for_relation(relation) + except IndexError: + raise IndexError(f"relation id {relation_id} cannot be accessed") + else: + return ( + all( + self._is_resource_created_for_relation(relation) for relation in self.relations + ) + if self.relations + else False + ) + + # Mandatory internal overrides + + @juju_secrets_only + def _get_relation_secret( + self, relation_id: int, group: SecretGroup, relation_name: Optional[str] = None + ) -> Optional[CachedSecret]: + """Retrieve a Juju Secret that's been stored in the relation databag.""" + if not relation_name: + relation_name = self.relation_name + + label = self._generate_secret_label(relation_name, relation_id, group) + return self.secrets.get(label) + + def _fetch_specific_relation_data( + self, relation, fields: Optional[List[str]] = None + ) -> Dict[str, str]: + """Fetching Requirer data -- that may include secrets.""" + if not relation.app: + return {} + return self._fetch_relation_data_with_secrets( + relation.app, self.secret_fields, relation, fields + ) + + def _fetch_my_specific_relation_data(self, relation, fields: Optional[List[str]]) -> dict: + """Fetching our own relation data.""" + return self._fetch_relation_data_without_secrets(self.local_app, relation, fields) + + def _update_relation_data(self, relation: Relation, data: dict) -> None: + """Updates a set of key-value pairs in the relation. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation: the particular relation. + data: dict containing the key-value pairs + that should be updated in the relation. + """ + return self._update_relation_data_without_secrets(self.local_app, relation, data) + + def _delete_relation_data(self, relation: Relation, fields: List[str]) -> None: + """Deletes a set of fields from the relation. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation: the particular relation. + fields: list containing the field names that should be removed from the relation. + """ + return self._delete_relation_data_without_secrets(self.local_app, relation, fields) + + # Public functions -- inherited + + fetch_my_relation_data = leader_only(Data.fetch_my_relation_data) + fetch_my_relation_field = leader_only(Data.fetch_my_relation_field) + + +class RequirerEventHandlers(EventHandlers): + """Requires-side of the relation.""" + + def __init__(self, charm: CharmBase, relation_data: RequirerData, unique_key: str = ""): + """Manager of base client relations.""" + super().__init__(charm, relation_data, unique_key) + + self.framework.observe( + self.charm.on[relation_data.relation_name].relation_created, + self._on_relation_created_event, + ) + self.framework.observe( + charm.on.secret_changed, + self._on_secret_changed_event, + ) + + # Event handlers + + def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: + """Event emitted when the relation is created.""" + if not self.relation_data.local_unit.is_leader(): + return + + if self.relation_data.secret_fields: # pyright: ignore [reportAttributeAccessIssue] + set_encoded_field( + event.relation, + self.relation_data.component, + REQ_SECRET_FIELDS, + self.relation_data.secret_fields, # pyright: ignore [reportAttributeAccessIssue] + ) + + @abstractmethod + def _on_secret_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the relation data has changed.""" + raise NotImplementedError + + +################################################################################ +# Peer Relation Data +################################################################################ + + +class DataPeerData(RequirerData, ProviderData): + """Represents peer relations data.""" + + SECRET_FIELDS = [] + SECRET_FIELD_NAME = "internal_secret" + SECRET_LABEL_MAP = {} + + def __init__( + self, + model, + relation_name: str, + extra_user_roles: Optional[str] = None, + additional_secret_fields: Optional[List[str]] = [], + additional_secret_group_mapping: Dict[str, str] = {}, + secret_field_name: Optional[str] = None, + deleted_label: Optional[str] = None, + ): + RequirerData.__init__( + self, + model, + relation_name, + extra_user_roles, + additional_secret_fields, + ) + self.secret_field_name = secret_field_name if secret_field_name else self.SECRET_FIELD_NAME + self.deleted_label = deleted_label + self._secret_label_map = {} + + # Legacy information holders + self._legacy_labels = [] + self._legacy_secret_uri = None + + # Secrets that are being dynamically added within the scope of this event handler run + self._new_secrets = [] + self._additional_secret_group_mapping = additional_secret_group_mapping + + for group, fields in additional_secret_group_mapping.items(): + if group not in SECRET_GROUPS.groups(): + setattr(SECRET_GROUPS, group, group) + for field in fields: + secret_group = SECRET_GROUPS.get_group(group) + internal_field = self._field_to_internal_name(field, secret_group) + self._secret_label_map.setdefault(group, []).append(internal_field) + self._secret_fields.append(internal_field) + + @property + def scope(self) -> Optional[Scope]: + """Turn component information into Scope.""" + if isinstance(self.component, Application): + return Scope.APP + if isinstance(self.component, Unit): + return Scope.UNIT + + @property + def secret_label_map(self) -> Dict[str, str]: + """Property storing secret mappings.""" + return self._secret_label_map + + @property + def static_secret_fields(self) -> List[str]: + """Re-definition of the property in a way that dynamically extended list is retrieved.""" + return self._secret_fields + + @property + def secret_fields(self) -> List[str]: + """Re-definition of the property in a way that dynamically extended list is retrieved.""" + return ( + self.static_secret_fields if self.static_secret_fields else self.current_secret_fields + ) + + @property + def current_secret_fields(self) -> List[str]: + """Helper method to get all currently existing secret fields (added statically or dynamically).""" + if not self.secrets_enabled: + return [] + + if len(self._model.relations[self.relation_name]) > 1: + raise ValueError(f"More than one peer relation on {self.relation_name}") + + relation = self._model.relations[self.relation_name][0] + fields = [] + + ignores = [SECRET_GROUPS.get_group("user"), SECRET_GROUPS.get_group("tls")] + for group in SECRET_GROUPS.groups(): + if group in ignores: + continue + if content := self._get_group_secret_contents(relation, group): + fields += list(content.keys()) + return list(set(fields) | set(self._new_secrets)) + + @dynamic_secrets_only + def set_secret( + self, + relation_id: int, + field: str, + value: str, + group_mapping: Optional[SecretGroup] = None, + ) -> None: + """Public interface method to add a Relation Data field specifically as a Juju Secret. + + Args: + relation_id: ID of the relation + field: The secret field that is to be added + value: The string value of the secret + group_mapping: The name of the "secret group", in case the field is to be added to an existing secret + """ + self._legacy_apply_on_update([field]) + + full_field = self._field_to_internal_name(field, group_mapping) + if self.secrets_enabled and full_field not in self.current_secret_fields: + self._new_secrets.append(full_field) + if self.valid_field_pattern(field, full_field): + self.update_relation_data(relation_id, {full_field: value}) + + # Unlike for set_secret(), there's no harm using this operation with static secrets + # The restricion is only added to keep the concept clear + @dynamic_secrets_only + def get_secret( + self, + relation_id: int, + field: str, + group_mapping: Optional[SecretGroup] = None, + ) -> Optional[str]: + """Public interface method to fetch secrets only.""" + self._legacy_apply_on_fetch() + + full_field = self._field_to_internal_name(field, group_mapping) + if ( + self.secrets_enabled + and full_field not in self.current_secret_fields + and field not in self.current_secret_fields + ): + return + if self.valid_field_pattern(field, full_field): + return self.fetch_my_relation_field(relation_id, full_field) + + @dynamic_secrets_only + def delete_secret( + self, + relation_id: int, + field: str, + group_mapping: Optional[SecretGroup] = None, + ) -> Optional[str]: + """Public interface method to delete secrets only.""" + self._legacy_apply_on_delete([field]) + + full_field = self._field_to_internal_name(field, group_mapping) + if self.secrets_enabled and full_field not in self.current_secret_fields: + logger.warning(f"Secret {field} from group {group_mapping} was not found") + return + + if self.valid_field_pattern(field, full_field): + self.delete_relation_data(relation_id, [full_field]) + + ########################################################################## + # Helpers + ########################################################################## + + @staticmethod + def _field_to_internal_name(field: str, group: Optional[SecretGroup]) -> str: + if not group or group == SECRET_GROUPS.EXTRA: + return field + return f"{field}{GROUP_SEPARATOR}{group}" + + @staticmethod + def _internal_name_to_field(name: str) -> Tuple[str, SecretGroup]: + parts = name.split(GROUP_SEPARATOR) + if not len(parts) > 1: + return (parts[0], SECRET_GROUPS.EXTRA) + secret_group = SECRET_GROUPS.get_group(parts[1]) + if not secret_group: + raise ValueError(f"Invalid secret field {name}") + return (parts[0], secret_group) + + def _group_secret_fields(self, secret_fields: List[str]) -> Dict[SecretGroup, List[str]]: + """Helper function to arrange secret mappings under their group. + + NOTE: All unrecognized items end up in the 'extra' secret bucket. + Make sure only secret fields are passed! + """ + secret_fieldnames_grouped = {} + for key in secret_fields: + field, group = self._internal_name_to_field(key) + secret_fieldnames_grouped.setdefault(group, []).append(field) + return secret_fieldnames_grouped + + def _content_for_secret_group( + self, content: Dict[str, str], secret_fields: Set[str], group_mapping: SecretGroup + ) -> Dict[str, str]: + """Select : pairs from input, that belong to this particular Secret group.""" + if group_mapping == SECRET_GROUPS.EXTRA: + return {k: v for k, v in content.items() if k in self.secret_fields} + return { + self._internal_name_to_field(k)[0]: v + for k, v in content.items() + if k in self.secret_fields + } + + def valid_field_pattern(self, field: str, full_field: str) -> bool: + """Check that no secret group is attempted to be used together without secrets being enabled. + + Secrets groups are impossible to use with versions that are not yet supporting secrets. + """ + if not self.secrets_enabled and full_field != field: + logger.error( + f"Can't access {full_field}: no secrets available (i.e. no secret groups either)." + ) + return False + return True + + ########################################################################## + # Backwards compatibility / Upgrades + ########################################################################## + # These functions are used to keep backwards compatibility on upgrades + # Policy: + # All data is kept intact until the first write operation. (This allows a minimal + # grace period during which rollbacks are fully safe. For more info see spec.) + # All data involves: + # - databag + # - secrets content + # - secret labels (!!!) + # Legacy functions must return None, and leave an equally consistent state whether + # they are executed or skipped (as a high enough versioned execution environment may + # not require so) + + # Full legacy stack for each operation + + def _legacy_apply_on_fetch(self) -> None: + """All legacy functions to be applied on fetch.""" + relation = self._model.relations[self.relation_name][0] + self._legacy_compat_generate_prev_labels() + self._legacy_compat_secret_uri_from_databag(relation) + + def _legacy_apply_on_update(self, fields) -> None: + """All legacy functions to be applied on update.""" + relation = self._model.relations[self.relation_name][0] + self._legacy_compat_generate_prev_labels() + self._legacy_compat_secret_uri_from_databag(relation) + self._legacy_migration_remove_secret_from_databag(relation, fields) + self._legacy_migration_remove_secret_field_name_from_databag(relation) + + def _legacy_apply_on_delete(self, fields) -> None: + """All legacy functions to be applied on delete.""" + relation = self._model.relations[self.relation_name][0] + self._legacy_compat_generate_prev_labels() + self._legacy_compat_secret_uri_from_databag(relation) + self._legacy_compat_check_deleted_label(relation, fields) + + # Compatibility + + @legacy_apply_from_version(18) + def _legacy_compat_check_deleted_label(self, relation, fields) -> None: + """Helper function for legacy behavior. + + As long as https://bugs.launchpad.net/juju/+bug/2028094 wasn't fixed, + we did not delete fields but rather kept them in the secret with a string value + expressing invalidity. This function is maintainnig that behavior when needed. + """ + if not self.deleted_label: + return + + current_data = self.fetch_my_relation_data([relation.id], fields) + if current_data is not None: + # Check if the secret we wanna delete actually exists + # Given the "deleted label", here we can't rely on the default mechanism (i.e. 'key not found') + if non_existent := (set(fields) & set(self.secret_fields)) - set( + current_data.get(relation.id, []) + ): + logger.debug( + "Non-existing secret %s was attempted to be removed.", + ", ".join(non_existent), + ) + + @legacy_apply_from_version(18) + def _legacy_compat_secret_uri_from_databag(self, relation) -> None: + """Fetching the secret URI from the databag, in case stored there.""" + self._legacy_secret_uri = relation.data[self.component].get( + self._generate_secret_field_name(), None + ) + + @legacy_apply_from_version(34) + def _legacy_compat_generate_prev_labels(self) -> None: + """Generator for legacy secret label names, for backwards compatibility. + + Secret label is part of the data that MUST be maintained across rolling upgrades. + In case there may be a change on a secret label, the old label must be recognized + after upgrades, and left intact until the first write operation -- when we roll over + to the new label. + + This function keeps "memory" of previously used secret labels. + NOTE: Return value takes decorator into account -- all 'legacy' functions may return `None` + + v0.34 (rev69): Fixing issue https://github.com/canonical/data-platform-libs/issues/155 + meant moving from '.' (i.e. 'mysql.app', 'mysql.unit') + to labels '..' (like 'peer.mysql.app') + """ + if self._legacy_labels: + return + + result = [] + members = [self._model.app.name] + if self.scope: + members.append(self.scope.value) + result.append(f"{'.'.join(members)}") + self._legacy_labels = result + + # Migration + + @legacy_apply_from_version(18) + def _legacy_migration_remove_secret_from_databag(self, relation, fields: List[str]) -> None: + """For Rolling Upgrades -- when moving from databag to secrets usage. + + Practically what happens here is to remove stuff from the databag that is + to be stored in secrets. + """ + if not self.secret_fields: + return + + secret_fields_passed = set(self.secret_fields) & set(fields) + for field in secret_fields_passed: + if self._fetch_relation_data_without_secrets(self.component, relation, [field]): + self._delete_relation_data_without_secrets(self.component, relation, [field]) + + @legacy_apply_from_version(18) + def _legacy_migration_remove_secret_field_name_from_databag(self, relation) -> None: + """Making sure that the old databag URI is gone. + + This action should not be executed more than once. + + There was a phase (before moving secrets usage to libs) when charms saved the peer + secret URI to the databag, and used this URI from then on to retrieve their secret. + When upgrading to charm versions using this library, we need to add a label to the + secret and access it via label from than on, and remove the old traces from the databag. + """ + # Nothing to do if 'internal-secret' is not in the databag + if not (relation.data[self.component].get(self._generate_secret_field_name())): + return + + # Making sure that the secret receives its label + # (This should have happened by the time we get here, rather an extra security measure.) + secret = self._get_relation_secret(relation.id) + + # Either app scope secret with leader executing, or unit scope secret + leader_or_unit_scope = self.component != self.local_app or self.local_unit.is_leader() + if secret and leader_or_unit_scope: + # Databag reference to the secret URI can be removed, now that it's labelled + relation.data[self.component].pop(self._generate_secret_field_name(), None) + + ########################################################################## + # Event handlers + ########################################################################## + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the relation has changed.""" + pass + + def _on_secret_changed_event(self, event: SecretChangedEvent) -> None: + """Event emitted when the secret has changed.""" + pass + + ########################################################################## + # Overrides of Relation Data handling functions + ########################################################################## + + def _generate_secret_label( + self, relation_name: str, relation_id: int, group_mapping: SecretGroup + ) -> str: + members = [relation_name, self._model.app.name] + if self.scope: + members.append(self.scope.value) + if group_mapping != SECRET_GROUPS.EXTRA: + members.append(group_mapping) + return f"{'.'.join(members)}" + + def _generate_secret_field_name(self, group_mapping: SecretGroup = SECRET_GROUPS.EXTRA) -> str: + """Generate unique group_mappings for secrets within a relation context.""" + return f"{self.secret_field_name}" + + @juju_secrets_only + def _get_relation_secret( + self, + relation_id: int, + group_mapping: SecretGroup = SECRET_GROUPS.EXTRA, + relation_name: Optional[str] = None, + ) -> Optional[CachedSecret]: + """Retrieve a Juju Secret specifically for peer relations. + + In case this code may be executed within a rolling upgrade, and we may need to + migrate secrets from the databag to labels, we make sure to stick the correct + label on the secret, and clean up the local databag. + """ + if not relation_name: + relation_name = self.relation_name + + relation = self._model.get_relation(relation_name, relation_id) + if not relation: + return + + label = self._generate_secret_label(relation_name, relation_id, group_mapping) + + # URI or legacy label is only to applied when moving single legacy secret to a (new) label + if group_mapping == SECRET_GROUPS.EXTRA: + # Fetching the secret with fallback to URI (in case label is not yet known) + # Label would we "stuck" on the secret in case it is found + return self.secrets.get( + label, self._legacy_secret_uri, legacy_labels=self._legacy_labels + ) + return self.secrets.get(label) + + def _get_group_secret_contents( + self, + relation: Relation, + group: SecretGroup, + secret_fields: Union[Set[str], List[str]] = [], + ) -> Dict[str, str]: + """Helper function to retrieve collective, requested contents of a secret.""" + secret_fields = [self._internal_name_to_field(k)[0] for k in secret_fields] + result = super()._get_group_secret_contents(relation, group, secret_fields) + if self.deleted_label: + result = {key: result[key] for key in result if result[key] != self.deleted_label} + if self._additional_secret_group_mapping: + return {self._field_to_internal_name(key, group): result[key] for key in result} + return result + + @either_static_or_dynamic_secrets + def _fetch_my_specific_relation_data( + self, relation: Relation, fields: Optional[List[str]] + ) -> Dict[str, str]: + """Fetch data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" + return self._fetch_relation_data_with_secrets( + self.component, self.secret_fields, relation, fields + ) + + @either_static_or_dynamic_secrets + def _update_relation_data(self, relation: Relation, data: Dict[str, str]) -> None: + """Update data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" + _, normal_fields = self._process_secret_fields( + relation, + self.secret_fields, + list(data), + self._add_or_update_relation_secrets, + data=data, + uri_to_databag=False, + ) + + normal_content = {k: v for k, v in data.items() if k in normal_fields} + self._update_relation_data_without_secrets(self.component, relation, normal_content) + + @either_static_or_dynamic_secrets + def _delete_relation_data(self, relation: Relation, fields: List[str]) -> None: + """Delete data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" + if self.secret_fields and self.deleted_label: + + _, normal_fields = self._process_secret_fields( + relation, + self.secret_fields, + fields, + self._update_relation_secret, + data={field: self.deleted_label for field in fields}, + ) + else: + _, normal_fields = self._process_secret_fields( + relation, self.secret_fields, fields, self._delete_relation_secret, fields=fields + ) + self._delete_relation_data_without_secrets(self.component, relation, list(normal_fields)) + + def fetch_relation_data( + self, + relation_ids: Optional[List[int]] = None, + fields: Optional[List[str]] = None, + relation_name: Optional[str] = None, + ) -> Dict[int, Dict[str, str]]: + """This method makes no sense for a Peer Relation.""" + raise NotImplementedError( + "Peer Relation only supports 'self-side' fetch methods: " + "fetch_my_relation_data() and fetch_my_relation_field()" + ) + + def fetch_relation_field( + self, relation_id: int, field: str, relation_name: Optional[str] = None + ) -> Optional[str]: + """This method makes no sense for a Peer Relation.""" + raise NotImplementedError( + "Peer Relation only supports 'self-side' fetch methods: " + "fetch_my_relation_data() and fetch_my_relation_field()" + ) + + ########################################################################## + # Public functions -- inherited + ########################################################################## + + fetch_my_relation_data = Data.fetch_my_relation_data + fetch_my_relation_field = Data.fetch_my_relation_field + + +class DataPeerEventHandlers(RequirerEventHandlers): + """Requires-side of the relation.""" + + def __init__(self, charm: CharmBase, relation_data: RequirerData, unique_key: str = ""): + """Manager of base client relations.""" + super().__init__(charm, relation_data, unique_key) + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the relation has changed.""" + pass + + def _on_secret_changed_event(self, event: SecretChangedEvent) -> None: + """Event emitted when the secret has changed.""" + pass + + +class DataPeer(DataPeerData, DataPeerEventHandlers): + """Represents peer relations.""" + + def __init__( + self, + charm, + relation_name: str, + extra_user_roles: Optional[str] = None, + additional_secret_fields: Optional[List[str]] = [], + additional_secret_group_mapping: Dict[str, str] = {}, + secret_field_name: Optional[str] = None, + deleted_label: Optional[str] = None, + unique_key: str = "", + ): + DataPeerData.__init__( + self, + charm.model, + relation_name, + extra_user_roles, + additional_secret_fields, + additional_secret_group_mapping, + secret_field_name, + deleted_label, + ) + DataPeerEventHandlers.__init__(self, charm, self, unique_key) + + +class DataPeerUnitData(DataPeerData): + """Unit data abstraction representation.""" + + SCOPE = Scope.UNIT + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class DataPeerUnit(DataPeerUnitData, DataPeerEventHandlers): + """Unit databag representation.""" + + def __init__( + self, + charm, + relation_name: str, + extra_user_roles: Optional[str] = None, + additional_secret_fields: Optional[List[str]] = [], + additional_secret_group_mapping: Dict[str, str] = {}, + secret_field_name: Optional[str] = None, + deleted_label: Optional[str] = None, + unique_key: str = "", + ): + DataPeerData.__init__( + self, + charm.model, + relation_name, + extra_user_roles, + additional_secret_fields, + additional_secret_group_mapping, + secret_field_name, + deleted_label, + ) + DataPeerEventHandlers.__init__(self, charm, self, unique_key) + + +class DataPeerOtherUnitData(DataPeerUnitData): + """Unit data abstraction representation.""" + + def __init__(self, unit: Unit, *args, **kwargs): + super().__init__(*args, **kwargs) + self.local_unit = unit + self.component = unit + + def update_relation_data(self, relation_id: int, data: dict) -> None: + """This method makes no sense for a Other Peer Relation.""" + raise NotImplementedError("It's not possible to update data of another unit.") + + def delete_relation_data(self, relation_id: int, fields: List[str]) -> None: + """This method makes no sense for a Other Peer Relation.""" + raise NotImplementedError("It's not possible to delete data of another unit.") + + +class DataPeerOtherUnitEventHandlers(DataPeerEventHandlers): + """Requires-side of the relation.""" + + def __init__(self, charm: CharmBase, relation_data: DataPeerUnitData): + """Manager of base client relations.""" + unique_key = f"{relation_data.relation_name}-{relation_data.local_unit.name}" + super().__init__(charm, relation_data, unique_key=unique_key) + + +class DataPeerOtherUnit(DataPeerOtherUnitData, DataPeerOtherUnitEventHandlers): + """Unit databag representation for another unit than the executor.""" + + def __init__( + self, + unit: Unit, + charm: CharmBase, + relation_name: str, + extra_user_roles: Optional[str] = None, + additional_secret_fields: Optional[List[str]] = [], + additional_secret_group_mapping: Dict[str, str] = {}, + secret_field_name: Optional[str] = None, + deleted_label: Optional[str] = None, + ): + DataPeerOtherUnitData.__init__( + self, + unit, + charm.model, + relation_name, + extra_user_roles, + additional_secret_fields, + additional_secret_group_mapping, + secret_field_name, + deleted_label, + ) + DataPeerOtherUnitEventHandlers.__init__(self, charm, self) + + +################################################################################ +# Cross-charm Relatoins Data Handling and Evenets +################################################################################ + +# Generic events + + +class ExtraRoleEvent(RelationEvent): + """Base class for data events.""" + + @property + def extra_user_roles(self) -> Optional[str]: + """Returns the extra user roles that were requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("extra-user-roles") + + +class RelationEventWithSecret(RelationEvent): + """Base class for Relation Events that need to handle secrets.""" + + @property + def _secrets(self) -> dict: + """Caching secrets to avoid fetching them each time a field is referrd. + + DON'T USE the encapsulated helper variable outside of this function + """ + if not hasattr(self, "_cached_secrets"): + self._cached_secrets = {} + return self._cached_secrets + + def _get_secret(self, group) -> Optional[Dict[str, str]]: + """Retrieving secrets.""" + if not self.app: + return + if not self._secrets.get(group): + self._secrets[group] = None + secret_field = f"{PROV_SECRET_PREFIX}{group}" + if secret_uri := self.relation.data[self.app].get(secret_field): + secret = self.framework.model.get_secret(id=secret_uri) + self._secrets[group] = secret.get_content() + return self._secrets[group] + + @property + def secrets_enabled(self): + """Is this Juju version allowing for Secrets usage?""" + return JujuVersion.from_environ().has_secrets + + +class AuthenticationEvent(RelationEventWithSecret): + """Base class for authentication fields for events. + + The amount of logic added here is not ideal -- but this was the only way to preserve + the interface when moving to Juju Secrets + """ + + @property + def username(self) -> Optional[str]: + """Returns the created username.""" + if not self.relation.app: + return None + + if self.secrets_enabled: + secret = self._get_secret("user") + if secret: + return secret.get("username") + + return self.relation.data[self.relation.app].get("username") + + @property + def password(self) -> Optional[str]: + """Returns the password for the created user.""" + if not self.relation.app: + return None + + if self.secrets_enabled: + secret = self._get_secret("user") + if secret: + return secret.get("password") + + return self.relation.data[self.relation.app].get("password") + + @property + def tls(self) -> Optional[str]: + """Returns whether TLS is configured.""" + if not self.relation.app: + return None + + if self.secrets_enabled: + secret = self._get_secret("tls") + if secret: + return secret.get("tls") + + return self.relation.data[self.relation.app].get("tls") + + @property + def tls_ca(self) -> Optional[str]: + """Returns TLS CA.""" + if not self.relation.app: + return None + + if self.secrets_enabled: + secret = self._get_secret("tls") + if secret: + return secret.get("tls-ca") + + return self.relation.data[self.relation.app].get("tls-ca") + + +# Database related events and fields + + +class DatabaseProvidesEvent(RelationEvent): + """Base class for database events.""" + + @property + def database(self) -> Optional[str]: + """Returns the database that was requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("database") + + +class DatabaseRequestedEvent(DatabaseProvidesEvent, ExtraRoleEvent): + """Event emitted when a new database is requested for use on this relation.""" + + @property + def external_node_connectivity(self) -> bool: + """Returns the requested external_node_connectivity field.""" + if not self.relation.app: + return False + + return ( + self.relation.data[self.relation.app].get("external-node-connectivity", "false") + == "true" + ) + + +class DatabaseProvidesEvents(CharmEvents): + """Database events. + + This class defines the events that the database can emit. + """ + + database_requested = EventSource(DatabaseRequestedEvent) + + +class DatabaseRequiresEvent(RelationEventWithSecret): + """Base class for database events.""" + + @property + def database(self) -> Optional[str]: + """Returns the database name.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("database") + + @property + def endpoints(self) -> Optional[str]: + """Returns a comma separated list of read/write endpoints. + + In VM charms, this is the primary's address. + In kubernetes charms, this is the service to the primary pod. + """ + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("endpoints") + + @property + def read_only_endpoints(self) -> Optional[str]: + """Returns a comma separated list of read only endpoints. + + In VM charms, this is the address of all the secondary instances. + In kubernetes charms, this is the service to all replica pod instances. + """ + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("read-only-endpoints") + + @property + def replset(self) -> Optional[str]: + """Returns the replicaset name. + + MongoDB only. + """ + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("replset") + + @property + def uris(self) -> Optional[str]: + """Returns the connection URIs. + + MongoDB, Redis, OpenSearch. + """ + if not self.relation.app: + return None + + if self.secrets_enabled: + secret = self._get_secret("user") + if secret: + return secret.get("uris") + + return self.relation.data[self.relation.app].get("uris") + + @property + def version(self) -> Optional[str]: + """Returns the version of the database. + + Version as informed by the database daemon. + """ + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("version") + + +class DatabaseCreatedEvent(AuthenticationEvent, DatabaseRequiresEvent): + """Event emitted when a new database is created for use on this relation.""" + + +class DatabaseEndpointsChangedEvent(AuthenticationEvent, DatabaseRequiresEvent): + """Event emitted when the read/write endpoints are changed.""" + + +class DatabaseReadOnlyEndpointsChangedEvent(AuthenticationEvent, DatabaseRequiresEvent): + """Event emitted when the read only endpoints are changed.""" + + +class DatabaseRequiresEvents(CharmEvents): + """Database events. + + This class defines the events that the database can emit. + """ + + database_created = EventSource(DatabaseCreatedEvent) + endpoints_changed = EventSource(DatabaseEndpointsChangedEvent) + read_only_endpoints_changed = EventSource(DatabaseReadOnlyEndpointsChangedEvent) + + +# Database Provider and Requires + + +class DatabaseProviderData(ProviderData): + """Provider-side data of the database relations.""" + + def __init__(self, model: Model, relation_name: str) -> None: + super().__init__(model, relation_name) + + def set_database(self, relation_id: int, database_name: str) -> None: + """Set database name. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + database_name: database name. + """ + self.update_relation_data(relation_id, {"database": database_name}) + + def set_endpoints(self, relation_id: int, connection_strings: str) -> None: + """Set database primary connections. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + In VM charms, only the primary's address should be passed as an endpoint. + In kubernetes charms, the service endpoint to the primary pod should be + passed as an endpoint. + + Args: + relation_id: the identifier for a particular relation. + connection_strings: database hosts and ports comma separated list. + """ + self.update_relation_data(relation_id, {"endpoints": connection_strings}) + + def set_read_only_endpoints(self, relation_id: int, connection_strings: str) -> None: + """Set database replicas connection strings. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + connection_strings: database hosts and ports comma separated list. + """ + self.update_relation_data(relation_id, {"read-only-endpoints": connection_strings}) + + def set_replset(self, relation_id: int, replset: str) -> None: + """Set replica set name in the application relation databag. + + MongoDB only. + + Args: + relation_id: the identifier for a particular relation. + replset: replica set name. + """ + self.update_relation_data(relation_id, {"replset": replset}) + + def set_uris(self, relation_id: int, uris: str) -> None: + """Set the database connection URIs in the application relation databag. + + MongoDB, Redis, and OpenSearch only. + + Args: + relation_id: the identifier for a particular relation. + uris: connection URIs. + """ + self.update_relation_data(relation_id, {"uris": uris}) + + def set_version(self, relation_id: int, version: str) -> None: + """Set the database version in the application relation databag. + + Args: + relation_id: the identifier for a particular relation. + version: database version. + """ + self.update_relation_data(relation_id, {"version": version}) + + def set_subordinated(self, relation_id: int) -> None: + """Raises the subordinated flag in the application relation databag. + + Args: + relation_id: the identifier for a particular relation. + """ + self.update_relation_data(relation_id, {"subordinated": "true"}) + + +class DatabaseProviderEventHandlers(EventHandlers): + """Provider-side of the database relation handlers.""" + + on = DatabaseProvidesEvents() # pyright: ignore [reportAssignmentType] + + def __init__( + self, charm: CharmBase, relation_data: DatabaseProviderData, unique_key: str = "" + ): + """Manager of base client relations.""" + super().__init__(charm, relation_data, unique_key) + # Just to calm down pyright, it can't parse that the same type is being used in the super() call above + self.relation_data = relation_data + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the relation has changed.""" + # Leader only + if not self.relation_data.local_unit.is_leader(): + return + # Check which data has changed to emit customs events. + diff = self._diff(event) + + # Emit a database requested event if the setup key (database name and optional + # extra user roles) was added to the relation databag by the application. + if "database" in diff.added: + getattr(self.on, "database_requested").emit( + event.relation, app=event.app, unit=event.unit + ) + + +class DatabaseProvides(DatabaseProviderData, DatabaseProviderEventHandlers): + """Provider-side of the database relations.""" + + def __init__(self, charm: CharmBase, relation_name: str) -> None: + DatabaseProviderData.__init__(self, charm.model, relation_name) + DatabaseProviderEventHandlers.__init__(self, charm, self) + + +class DatabaseRequirerData(RequirerData): + """Requirer-side of the database relation.""" + + def __init__( + self, + model: Model, + relation_name: str, + database_name: str, + extra_user_roles: Optional[str] = None, + relations_aliases: Optional[List[str]] = None, + additional_secret_fields: Optional[List[str]] = [], + external_node_connectivity: bool = False, + ): + """Manager of database client relations.""" + super().__init__(model, relation_name, extra_user_roles, additional_secret_fields) + self.database = database_name + self.relations_aliases = relations_aliases + self.external_node_connectivity = external_node_connectivity + + def is_postgresql_plugin_enabled(self, plugin: str, relation_index: int = 0) -> bool: + """Returns whether a plugin is enabled in the database. + + Args: + plugin: name of the plugin to check. + relation_index: optional relation index to check the database + (default: 0 - first relation). + + PostgreSQL only. + """ + # Psycopg 3 is imported locally to avoid the need of its package installation + # when relating to a database charm other than PostgreSQL. + import psycopg + + # Return False if no relation is established. + if len(self.relations) == 0: + return False + + relation_id = self.relations[relation_index].id + host = self.fetch_relation_field(relation_id, "endpoints") + + # Return False if there is no endpoint available. + if host is None: + return False + + host = host.split(":")[0] + + content = self.fetch_relation_data([relation_id], ["username", "password"]).get( + relation_id, {} + ) + user = content.get("username") + password = content.get("password") + + connection_string = ( + f"host='{host}' dbname='{self.database}' user='{user}' password='{password}'" + ) + try: + with psycopg.connect(connection_string) as connection: + with connection.cursor() as cursor: + cursor.execute( + "SELECT TRUE FROM pg_extension WHERE extname=%s::text;", (plugin,) + ) + return cursor.fetchone() is not None + except psycopg.Error as e: + logger.exception( + f"failed to check whether {plugin} plugin is enabled in the database: %s", str(e) + ) + return False + + +class DatabaseRequirerEventHandlers(RequirerEventHandlers): + """Requires-side of the relation.""" + + on = DatabaseRequiresEvents() # pyright: ignore [reportAssignmentType] + + def __init__( + self, charm: CharmBase, relation_data: DatabaseRequirerData, unique_key: str = "" + ): + """Manager of base client relations.""" + super().__init__(charm, relation_data, unique_key) + # Just to keep lint quiet, can't resolve inheritance. The same happened in super().__init__() above + self.relation_data = relation_data + + # Define custom event names for each alias. + if self.relation_data.relations_aliases: + # Ensure the number of aliases does not exceed the maximum + # of connections allowed in the specific relation. + relation_connection_limit = self.charm.meta.requires[ + self.relation_data.relation_name + ].limit + if len(self.relation_data.relations_aliases) != relation_connection_limit: + raise ValueError( + f"The number of aliases must match the maximum number of connections allowed in the relation. " + f"Expected {relation_connection_limit}, got {len(self.relation_data.relations_aliases)}" + ) + + if self.relation_data.relations_aliases: + for relation_alias in self.relation_data.relations_aliases: + self.on.define_event(f"{relation_alias}_database_created", DatabaseCreatedEvent) + self.on.define_event( + f"{relation_alias}_endpoints_changed", DatabaseEndpointsChangedEvent + ) + self.on.define_event( + f"{relation_alias}_read_only_endpoints_changed", + DatabaseReadOnlyEndpointsChangedEvent, + ) + + def _on_secret_changed_event(self, event: SecretChangedEvent): + """Event notifying about a new value of a secret.""" + pass + + def _assign_relation_alias(self, relation_id: int) -> None: + """Assigns an alias to a relation. + + This function writes in the unit data bag. + + Args: + relation_id: the identifier for a particular relation. + """ + # If no aliases were provided, return immediately. + if not self.relation_data.relations_aliases: + return + + # Return if an alias was already assigned to this relation + # (like when there are more than one unit joining the relation). + relation = self.charm.model.get_relation(self.relation_data.relation_name, relation_id) + if relation and relation.data[self.relation_data.local_unit].get("alias"): + return + + # Retrieve the available aliases (the ones that weren't assigned to any relation). + available_aliases = self.relation_data.relations_aliases[:] + for relation in self.charm.model.relations[self.relation_data.relation_name]: + alias = relation.data[self.relation_data.local_unit].get("alias") + if alias: + logger.debug("Alias %s was already assigned to relation %d", alias, relation.id) + available_aliases.remove(alias) + + # Set the alias in the unit relation databag of the specific relation. + relation = self.charm.model.get_relation(self.relation_data.relation_name, relation_id) + if relation: + relation.data[self.relation_data.local_unit].update({"alias": available_aliases[0]}) + + # We need to set relation alias also on the application level so, + # it will be accessible in show-unit juju command, executed for a consumer application unit + if self.relation_data.local_unit.is_leader(): + self.relation_data.update_relation_data(relation_id, {"alias": available_aliases[0]}) + + def _emit_aliased_event(self, event: RelationChangedEvent, event_name: str) -> None: + """Emit an aliased event to a particular relation if it has an alias. + + Args: + event: the relation changed event that was received. + event_name: the name of the event to emit. + """ + alias = self._get_relation_alias(event.relation.id) + if alias: + getattr(self.on, f"{alias}_{event_name}").emit( + event.relation, app=event.app, unit=event.unit + ) + + def _get_relation_alias(self, relation_id: int) -> Optional[str]: + """Returns the relation alias. + + Args: + relation_id: the identifier for a particular relation. + + Returns: + the relation alias or None if the relation was not found. + """ + for relation in self.charm.model.relations[self.relation_data.relation_name]: + if relation.id == relation_id: + return relation.data[self.relation_data.local_unit].get("alias") + return None + + def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: + """Event emitted when the database relation is created.""" + super()._on_relation_created_event(event) + + # If relations aliases were provided, assign one to the relation. + self._assign_relation_alias(event.relation.id) + + # Sets both database and extra user roles in the relation + # if the roles are provided. Otherwise, sets only the database. + if not self.relation_data.local_unit.is_leader(): + return + + event_data = {"database": self.relation_data.database} + + if self.relation_data.extra_user_roles: + event_data["extra-user-roles"] = self.relation_data.extra_user_roles + + # set external-node-connectivity field + if self.relation_data.external_node_connectivity: + event_data["external-node-connectivity"] = "true" + + self.relation_data.update_relation_data(event.relation.id, event_data) + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the database relation has changed.""" + is_subordinate = False + remote_unit_data = None + for key in event.relation.data.keys(): + if isinstance(key, Unit) and not key.name.startswith(self.charm.app.name): + remote_unit_data = event.relation.data[key] + elif isinstance(key, Application) and key.name != self.charm.app.name: + is_subordinate = event.relation.data[key].get("subordinated") == "true" + + if is_subordinate: + if not remote_unit_data: + return + + if remote_unit_data.get("state") != "ready": + return + + # Check which data has changed to emit customs events. + diff = self._diff(event) + + # Register all new secrets with their labels + if any(newval for newval in diff.added if self.relation_data._is_secret_field(newval)): + self.relation_data._register_secrets_to_relation(event.relation, diff.added) + + # Check if the database is created + # (the database charm shared the credentials). + secret_field_user = self.relation_data._generate_secret_field_name(SECRET_GROUPS.USER) + if ( + "username" in diff.added and "password" in diff.added + ) or secret_field_user in diff.added: + # Emit the default event (the one without an alias). + logger.info("database created at %s", datetime.now()) + getattr(self.on, "database_created").emit( + event.relation, app=event.app, unit=event.unit + ) + + # Emit the aliased event (if any). + self._emit_aliased_event(event, "database_created") + + # To avoid unnecessary application restarts do not trigger + # “endpoints_changed“ event if “database_created“ is triggered. + return + + # Emit an endpoints changed event if the database + # added or changed this info in the relation databag. + if "endpoints" in diff.added or "endpoints" in diff.changed: + # Emit the default event (the one without an alias). + logger.info("endpoints changed on %s", datetime.now()) + getattr(self.on, "endpoints_changed").emit( + event.relation, app=event.app, unit=event.unit + ) + + # Emit the aliased event (if any). + self._emit_aliased_event(event, "endpoints_changed") + + # To avoid unnecessary application restarts do not trigger + # “read_only_endpoints_changed“ event if “endpoints_changed“ is triggered. + return + + # Emit a read only endpoints changed event if the database + # added or changed this info in the relation databag. + if "read-only-endpoints" in diff.added or "read-only-endpoints" in diff.changed: + # Emit the default event (the one without an alias). + logger.info("read-only-endpoints changed on %s", datetime.now()) + getattr(self.on, "read_only_endpoints_changed").emit( + event.relation, app=event.app, unit=event.unit + ) + + # Emit the aliased event (if any). + self._emit_aliased_event(event, "read_only_endpoints_changed") + + +class DatabaseRequires(DatabaseRequirerData, DatabaseRequirerEventHandlers): + """Provider-side of the database relations.""" + + def __init__( + self, + charm: CharmBase, + relation_name: str, + database_name: str, + extra_user_roles: Optional[str] = None, + relations_aliases: Optional[List[str]] = None, + additional_secret_fields: Optional[List[str]] = [], + external_node_connectivity: bool = False, + ): + DatabaseRequirerData.__init__( + self, + charm.model, + relation_name, + database_name, + extra_user_roles, + relations_aliases, + additional_secret_fields, + external_node_connectivity, + ) + DatabaseRequirerEventHandlers.__init__(self, charm, self) + + +################################################################################ +# Charm-specific Relations Data and Events +################################################################################ + +# Kafka Events + + +class KafkaProvidesEvent(RelationEvent): + """Base class for Kafka events.""" + + @property + def topic(self) -> Optional[str]: + """Returns the topic that was requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("topic") + + @property + def consumer_group_prefix(self) -> Optional[str]: + """Returns the consumer-group-prefix that was requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("consumer-group-prefix") + + +class TopicRequestedEvent(KafkaProvidesEvent, ExtraRoleEvent): + """Event emitted when a new topic is requested for use on this relation.""" + + +class KafkaProvidesEvents(CharmEvents): + """Kafka events. + + This class defines the events that the Kafka can emit. + """ + + topic_requested = EventSource(TopicRequestedEvent) + + +class KafkaRequiresEvent(RelationEvent): + """Base class for Kafka events.""" + + @property + def topic(self) -> Optional[str]: + """Returns the topic.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("topic") + + @property + def bootstrap_server(self) -> Optional[str]: + """Returns a comma-separated list of broker uris.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("endpoints") + + @property + def consumer_group_prefix(self) -> Optional[str]: + """Returns the consumer-group-prefix.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("consumer-group-prefix") + + @property + def zookeeper_uris(self) -> Optional[str]: + """Returns a comma separated list of Zookeeper uris.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("zookeeper-uris") + + +class TopicCreatedEvent(AuthenticationEvent, KafkaRequiresEvent): + """Event emitted when a new topic is created for use on this relation.""" + + +class BootstrapServerChangedEvent(AuthenticationEvent, KafkaRequiresEvent): + """Event emitted when the bootstrap server is changed.""" + + +class KafkaRequiresEvents(CharmEvents): + """Kafka events. + + This class defines the events that the Kafka can emit. + """ + + topic_created = EventSource(TopicCreatedEvent) + bootstrap_server_changed = EventSource(BootstrapServerChangedEvent) + + +# Kafka Provides and Requires + + +class KafkaProviderData(ProviderData): + """Provider-side of the Kafka relation.""" + + RESOURCE_FIELD = "topic" + + def __init__(self, model: Model, relation_name: str) -> None: + super().__init__(model, relation_name) + + def set_topic(self, relation_id: int, topic: str) -> None: + """Set topic name in the application relation databag. + + Args: + relation_id: the identifier for a particular relation. + topic: the topic name. + """ + self.update_relation_data(relation_id, {"topic": topic}) + + def set_bootstrap_server(self, relation_id: int, bootstrap_server: str) -> None: + """Set the bootstrap server in the application relation databag. + + Args: + relation_id: the identifier for a particular relation. + bootstrap_server: the bootstrap server address. + """ + self.update_relation_data(relation_id, {"endpoints": bootstrap_server}) + + def set_consumer_group_prefix(self, relation_id: int, consumer_group_prefix: str) -> None: + """Set the consumer group prefix in the application relation databag. + + Args: + relation_id: the identifier for a particular relation. + consumer_group_prefix: the consumer group prefix string. + """ + self.update_relation_data(relation_id, {"consumer-group-prefix": consumer_group_prefix}) + + def set_zookeeper_uris(self, relation_id: int, zookeeper_uris: str) -> None: + """Set the zookeeper uris in the application relation databag. + + Args: + relation_id: the identifier for a particular relation. + zookeeper_uris: comma-separated list of ZooKeeper server uris. + """ + self.update_relation_data(relation_id, {"zookeeper-uris": zookeeper_uris}) + + +class KafkaProviderEventHandlers(EventHandlers): + """Provider-side of the Kafka relation.""" + + on = KafkaProvidesEvents() # pyright: ignore [reportAssignmentType] + + def __init__(self, charm: CharmBase, relation_data: KafkaProviderData) -> None: + super().__init__(charm, relation_data) + # Just to keep lint quiet, can't resolve inheritance. The same happened in super().__init__() above + self.relation_data = relation_data + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the relation has changed.""" + # Leader only + if not self.relation_data.local_unit.is_leader(): + return + + # Check which data has changed to emit customs events. + diff = self._diff(event) + + # Emit a topic requested event if the setup key (topic name and optional + # extra user roles) was added to the relation databag by the application. + if "topic" in diff.added: + getattr(self.on, "topic_requested").emit( + event.relation, app=event.app, unit=event.unit + ) + + +class KafkaProvides(KafkaProviderData, KafkaProviderEventHandlers): + """Provider-side of the Kafka relation.""" + + def __init__(self, charm: CharmBase, relation_name: str) -> None: + KafkaProviderData.__init__(self, charm.model, relation_name) + KafkaProviderEventHandlers.__init__(self, charm, self) + + +class KafkaRequirerData(RequirerData): + """Requirer-side of the Kafka relation.""" + + def __init__( + self, + model: Model, + relation_name: str, + topic: str, + extra_user_roles: Optional[str] = None, + consumer_group_prefix: Optional[str] = None, + additional_secret_fields: Optional[List[str]] = [], + ): + """Manager of Kafka client relations.""" + super().__init__(model, relation_name, extra_user_roles, additional_secret_fields) + self.topic = topic + self.consumer_group_prefix = consumer_group_prefix or "" + + @property + def topic(self): + """Topic to use in Kafka.""" + return self._topic + + @topic.setter + def topic(self, value): + # Avoid wildcards + if value == "*": + raise ValueError(f"Error on topic '{value}', cannot be a wildcard.") + self._topic = value + + +class KafkaRequirerEventHandlers(RequirerEventHandlers): + """Requires-side of the Kafka relation.""" + + on = KafkaRequiresEvents() # pyright: ignore [reportAssignmentType] + + def __init__(self, charm: CharmBase, relation_data: KafkaRequirerData) -> None: + super().__init__(charm, relation_data) + # Just to keep lint quiet, can't resolve inheritance. The same happened in super().__init__() above + self.relation_data = relation_data + + def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: + """Event emitted when the Kafka relation is created.""" + super()._on_relation_created_event(event) + + if not self.relation_data.local_unit.is_leader(): + return + + # Sets topic, extra user roles, and "consumer-group-prefix" in the relation + relation_data = {"topic": self.relation_data.topic} + + if self.relation_data.extra_user_roles: + relation_data["extra-user-roles"] = self.relation_data.extra_user_roles + + if self.relation_data.consumer_group_prefix: + relation_data["consumer-group-prefix"] = self.relation_data.consumer_group_prefix + + self.relation_data.update_relation_data(event.relation.id, relation_data) + + def _on_secret_changed_event(self, event: SecretChangedEvent): + """Event notifying about a new value of a secret.""" + pass + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the Kafka relation has changed.""" + # Check which data has changed to emit customs events. + diff = self._diff(event) + + # Check if the topic is created + # (the Kafka charm shared the credentials). + + # Register all new secrets with their labels + if any(newval for newval in diff.added if self.relation_data._is_secret_field(newval)): + self.relation_data._register_secrets_to_relation(event.relation, diff.added) + + secret_field_user = self.relation_data._generate_secret_field_name(SECRET_GROUPS.USER) + if ( + "username" in diff.added and "password" in diff.added + ) or secret_field_user in diff.added: + # Emit the default event (the one without an alias). + logger.info("topic created at %s", datetime.now()) + getattr(self.on, "topic_created").emit(event.relation, app=event.app, unit=event.unit) + + # To avoid unnecessary application restarts do not trigger + # “endpoints_changed“ event if “topic_created“ is triggered. + return + + # Emit an endpoints (bootstrap-server) changed event if the Kafka endpoints + # added or changed this info in the relation databag. + if "endpoints" in diff.added or "endpoints" in diff.changed: + # Emit the default event (the one without an alias). + logger.info("endpoints changed on %s", datetime.now()) + getattr(self.on, "bootstrap_server_changed").emit( + event.relation, app=event.app, unit=event.unit + ) # here check if this is the right design + return + + +class KafkaRequires(KafkaRequirerData, KafkaRequirerEventHandlers): + """Provider-side of the Kafka relation.""" + + def __init__( + self, + charm: CharmBase, + relation_name: str, + topic: str, + extra_user_roles: Optional[str] = None, + consumer_group_prefix: Optional[str] = None, + additional_secret_fields: Optional[List[str]] = [], + ) -> None: + KafkaRequirerData.__init__( + self, + charm.model, + relation_name, + topic, + extra_user_roles, + consumer_group_prefix, + additional_secret_fields, + ) + KafkaRequirerEventHandlers.__init__(self, charm, self) + + +# Opensearch related events + + +class OpenSearchProvidesEvent(RelationEvent): + """Base class for OpenSearch events.""" + + @property + def index(self) -> Optional[str]: + """Returns the index that was requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("index") + + +class IndexRequestedEvent(OpenSearchProvidesEvent, ExtraRoleEvent): + """Event emitted when a new index is requested for use on this relation.""" + + +class OpenSearchProvidesEvents(CharmEvents): + """OpenSearch events. + + This class defines the events that OpenSearch can emit. + """ + + index_requested = EventSource(IndexRequestedEvent) + + +class OpenSearchRequiresEvent(DatabaseRequiresEvent): + """Base class for OpenSearch requirer events.""" + + +class IndexCreatedEvent(AuthenticationEvent, OpenSearchRequiresEvent): + """Event emitted when a new index is created for use on this relation.""" + + +class OpenSearchRequiresEvents(CharmEvents): + """OpenSearch events. + + This class defines the events that the opensearch requirer can emit. + """ + + index_created = EventSource(IndexCreatedEvent) + endpoints_changed = EventSource(DatabaseEndpointsChangedEvent) + authentication_updated = EventSource(AuthenticationEvent) + + +# OpenSearch Provides and Requires Objects + + +class OpenSearchProvidesData(ProviderData): + """Provider-side of the OpenSearch relation.""" + + RESOURCE_FIELD = "index" + + def __init__(self, model: Model, relation_name: str) -> None: + super().__init__(model, relation_name) + + def set_index(self, relation_id: int, index: str) -> None: + """Set the index in the application relation databag. + + Args: + relation_id: the identifier for a particular relation. + index: the index as it is _created_ on the provider charm. This needn't match the + requested index, and can be used to present a different index name if, for example, + the requested index is invalid. + """ + self.update_relation_data(relation_id, {"index": index}) + + def set_endpoints(self, relation_id: int, endpoints: str) -> None: + """Set the endpoints in the application relation databag. + + Args: + relation_id: the identifier for a particular relation. + endpoints: the endpoint addresses for opensearch nodes. + """ + self.update_relation_data(relation_id, {"endpoints": endpoints}) + + def set_version(self, relation_id: int, version: str) -> None: + """Set the opensearch version in the application relation databag. + + Args: + relation_id: the identifier for a particular relation. + version: database version. + """ + self.update_relation_data(relation_id, {"version": version}) + + +class OpenSearchProvidesEventHandlers(EventHandlers): + """Provider-side of the OpenSearch relation.""" + + on = OpenSearchProvidesEvents() # pyright: ignore[reportAssignmentType] + + def __init__(self, charm: CharmBase, relation_data: OpenSearchProvidesData) -> None: + super().__init__(charm, relation_data) + # Just to keep lint quiet, can't resolve inheritance. The same happened in super().__init__() above + self.relation_data = relation_data + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the relation has changed.""" + # Leader only + if not self.relation_data.local_unit.is_leader(): + return + # Check which data has changed to emit customs events. + diff = self._diff(event) + + # Emit an index requested event if the setup key (index name and optional extra user roles) + # have been added to the relation databag by the application. + if "index" in diff.added: + getattr(self.on, "index_requested").emit( + event.relation, app=event.app, unit=event.unit + ) + + +class OpenSearchProvides(OpenSearchProvidesData, OpenSearchProvidesEventHandlers): + """Provider-side of the OpenSearch relation.""" + + def __init__(self, charm: CharmBase, relation_name: str) -> None: + OpenSearchProvidesData.__init__(self, charm.model, relation_name) + OpenSearchProvidesEventHandlers.__init__(self, charm, self) + + +class OpenSearchRequiresData(RequirerData): + """Requires data side of the OpenSearch relation.""" + + def __init__( + self, + model: Model, + relation_name: str, + index: str, + extra_user_roles: Optional[str] = None, + additional_secret_fields: Optional[List[str]] = [], + ): + """Manager of OpenSearch client relations.""" + super().__init__(model, relation_name, extra_user_roles, additional_secret_fields) + self.index = index + + +class OpenSearchRequiresEventHandlers(RequirerEventHandlers): + """Requires events side of the OpenSearch relation.""" + + on = OpenSearchRequiresEvents() # pyright: ignore[reportAssignmentType] + + def __init__(self, charm: CharmBase, relation_data: OpenSearchRequiresData) -> None: + super().__init__(charm, relation_data) + # Just to keep lint quiet, can't resolve inheritance. The same happened in super().__init__() above + self.relation_data = relation_data + + def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: + """Event emitted when the OpenSearch relation is created.""" + super()._on_relation_created_event(event) + + if not self.relation_data.local_unit.is_leader(): + return + + # Sets both index and extra user roles in the relation if the roles are provided. + # Otherwise, sets only the index. + data = {"index": self.relation_data.index} + if self.relation_data.extra_user_roles: + data["extra-user-roles"] = self.relation_data.extra_user_roles + + self.relation_data.update_relation_data(event.relation.id, data) + + def _on_secret_changed_event(self, event: SecretChangedEvent): + """Event notifying about a new value of a secret.""" + if not event.secret.label: + return + + relation = self.relation_data._relation_from_secret_label(event.secret.label) + if not relation: + logging.info( + f"Received secret {event.secret.label} but couldn't parse, seems irrelevant" + ) + return + + if relation.app == self.charm.app: + logging.info("Secret changed event ignored for Secret Owner") + + remote_unit = None + for unit in relation.units: + if unit.app != self.charm.app: + remote_unit = unit + + logger.info("authentication updated") + getattr(self.on, "authentication_updated").emit( + relation, app=relation.app, unit=remote_unit + ) + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the OpenSearch relation has changed. + + This event triggers individual custom events depending on the changing relation. + """ + # Check which data has changed to emit customs events. + diff = self._diff(event) + + # Register all new secrets with their labels + if any(newval for newval in diff.added if self.relation_data._is_secret_field(newval)): + self.relation_data._register_secrets_to_relation(event.relation, diff.added) + + secret_field_user = self.relation_data._generate_secret_field_name(SECRET_GROUPS.USER) + secret_field_tls = self.relation_data._generate_secret_field_name(SECRET_GROUPS.TLS) + updates = {"username", "password", "tls", "tls-ca", secret_field_user, secret_field_tls} + if len(set(diff._asdict().keys()) - updates) < len(diff): + logger.info("authentication updated at: %s", datetime.now()) + getattr(self.on, "authentication_updated").emit( + event.relation, app=event.app, unit=event.unit + ) + + # Check if the index is created + # (the OpenSearch charm shares the credentials). + if ( + "username" in diff.added and "password" in diff.added + ) or secret_field_user in diff.added: + # Emit the default event (the one without an alias). + logger.info("index created at: %s", datetime.now()) + getattr(self.on, "index_created").emit(event.relation, app=event.app, unit=event.unit) + + # To avoid unnecessary application restarts do not trigger + # “endpoints_changed“ event if “index_created“ is triggered. + return + + # Emit a endpoints changed event if the OpenSearch application added or changed this info + # in the relation databag. + if "endpoints" in diff.added or "endpoints" in diff.changed: + # Emit the default event (the one without an alias). + logger.info("endpoints changed on %s", datetime.now()) + getattr(self.on, "endpoints_changed").emit( + event.relation, app=event.app, unit=event.unit + ) # here check if this is the right design + return + + +class OpenSearchRequires(OpenSearchRequiresData, OpenSearchRequiresEventHandlers): + """Requires-side of the OpenSearch relation.""" + + def __init__( + self, + charm: CharmBase, + relation_name: str, + index: str, + extra_user_roles: Optional[str] = None, + additional_secret_fields: Optional[List[str]] = [], + ) -> None: + OpenSearchRequiresData.__init__( + self, + charm.model, + relation_name, + index, + extra_user_roles, + additional_secret_fields, + ) + OpenSearchRequiresEventHandlers.__init__(self, charm, self) diff --git a/metadata.yaml b/metadata.yaml index 63f856d..ac362ed 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -4,7 +4,6 @@ # This file is still required due to data-platform-workflows not supporting unified # charmcraft.yaml syntax. Details see: # https://github.com/canonical/data-platform-workflows/issues/169 - name: charmed-etcd-operator title: Charmed etcd VM operator summary: Operator for etcd databases in VM environments. @@ -12,3 +11,7 @@ description: | Etcd is a distributed, reliable key-value store for the most critical data of distributed systems, such as Kubernetes. This charmed operator deploys and operates etcd on virtual machines. + +peers: + etcd-cluster: + interface: etcd_cluster \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index a708e99..927bf95 100644 --- a/poetry.lock +++ b/poetry.lock @@ -667,22 +667,22 @@ test-extra = ["curio", "ipython[test]", "matplotlib (!=3.2.0)", "nbformat", "num [[package]] name = "jedi" -version = "0.19.1" +version = "0.19.2" description = "An autocompletion tool for Python that can be used for text editors." optional = false python-versions = ">=3.6" files = [ - {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, - {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, + {file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"}, + {file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"}, ] [package.dependencies] -parso = ">=0.8.3,<0.9.0" +parso = ">=0.8.4,<0.9.0" [package.extras] docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] -testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<9.0.0)"] [[package]] name = "jinja2" @@ -955,13 +955,13 @@ docs = ["canonical-sphinx-extensions", "furo", "linkify-it-py", "myst-parser", " [[package]] name = "packaging" -version = "24.1" +version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] [[package]] @@ -1663,29 +1663,29 @@ pyasn1 = ">=0.1.3" [[package]] name = "ruff" -version = "0.7.2" +version = "0.7.3" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.7.2-py3-none-linux_armv6l.whl", hash = "sha256:b73f873b5f52092e63ed540adefc3c36f1f803790ecf2590e1df8bf0a9f72cb8"}, - {file = "ruff-0.7.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5b813ef26db1015953daf476202585512afd6a6862a02cde63f3bafb53d0b2d4"}, - {file = "ruff-0.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:853277dbd9675810c6826dad7a428d52a11760744508340e66bf46f8be9701d9"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21aae53ab1490a52bf4e3bf520c10ce120987b047c494cacf4edad0ba0888da2"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ccc7e0fc6e0cb3168443eeadb6445285abaae75142ee22b2b72c27d790ab60ba"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd77877a4e43b3a98e5ef4715ba3862105e299af0c48942cc6d51ba3d97dc859"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e00163fb897d35523c70d71a46fbaa43bf7bf9af0f4534c53ea5b96b2e03397b"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3c54b538633482dc342e9b634d91168fe8cc56b30a4b4f99287f4e339103e88"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b792468e9804a204be221b14257566669d1db5c00d6bb335996e5cd7004ba80"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dba53ed84ac19ae4bfb4ea4bf0172550a2285fa27fbb13e3746f04c80f7fa088"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b19fafe261bf741bca2764c14cbb4ee1819b67adb63ebc2db6401dcd652e3748"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28bd8220f4d8f79d590db9e2f6a0674f75ddbc3847277dd44ac1f8d30684b828"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9fd67094e77efbea932e62b5d2483006154794040abb3a5072e659096415ae1e"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:576305393998b7bd6c46018f8104ea3a9cb3fa7908c21d8580e3274a3b04b691"}, - {file = "ruff-0.7.2-py3-none-win32.whl", hash = "sha256:fa993cfc9f0ff11187e82de874dfc3611df80852540331bc85c75809c93253a8"}, - {file = "ruff-0.7.2-py3-none-win_amd64.whl", hash = "sha256:dd8800cbe0254e06b8fec585e97554047fb82c894973f7ff18558eee33d1cb88"}, - {file = "ruff-0.7.2-py3-none-win_arm64.whl", hash = "sha256:bb8368cd45bba3f57bb29cbb8d64b4a33f8415d0149d2655c5c8539452ce7760"}, - {file = "ruff-0.7.2.tar.gz", hash = "sha256:2b14e77293380e475b4e3a7a368e14549288ed2931fce259a6f99978669e844f"}, + {file = "ruff-0.7.3-py3-none-linux_armv6l.whl", hash = "sha256:34f2339dc22687ec7e7002792d1f50712bf84a13d5152e75712ac08be565d344"}, + {file = "ruff-0.7.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fb397332a1879b9764a3455a0bb1087bda876c2db8aca3a3cbb67b3dbce8cda0"}, + {file = "ruff-0.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37d0b619546103274e7f62643d14e1adcbccb242efda4e4bdb9544d7764782e9"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d59f0c3ee4d1a6787614e7135b72e21024875266101142a09a61439cb6e38a5"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44eb93c2499a169d49fafd07bc62ac89b1bc800b197e50ff4633aed212569299"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d0242ce53f3a576c35ee32d907475a8d569944c0407f91d207c8af5be5dae4e"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6b6224af8b5e09772c2ecb8dc9f3f344c1aa48201c7f07e7315367f6dd90ac29"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c50f95a82b94421c964fae4c27c0242890a20fe67d203d127e84fbb8013855f5"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f3eff9961b5d2644bcf1616c606e93baa2d6b349e8aa8b035f654df252c8c67"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8963cab06d130c4df2fd52c84e9f10d297826d2e8169ae0c798b6221be1d1d2"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:61b46049d6edc0e4317fb14b33bd693245281a3007288b68a3f5b74a22a0746d"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:10ebce7696afe4644e8c1a23b3cf8c0f2193a310c18387c06e583ae9ef284de2"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3f36d56326b3aef8eeee150b700e519880d1aab92f471eefdef656fd57492aa2"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5d024301109a0007b78d57ab0ba190087b43dce852e552734ebf0b0b85e4fb16"}, + {file = "ruff-0.7.3-py3-none-win32.whl", hash = "sha256:4ba81a5f0c5478aa61674c5a2194de8b02652f17addf8dfc40c8937e6e7d79fc"}, + {file = "ruff-0.7.3-py3-none-win_amd64.whl", hash = "sha256:588a9ff2fecf01025ed065fe28809cd5a53b43505f48b69a1ac7707b1b7e4088"}, + {file = "ruff-0.7.3-py3-none-win_arm64.whl", hash = "sha256:1713e2c5545863cdbfe2cbce21f69ffaf37b813bfd1fb3b90dc9a6f1963f5a8c"}, + {file = "ruff-0.7.3.tar.gz", hash = "sha256:e1d1ba2e40b6e71a61b063354d04be669ab0d39c352461f3d789cac68b54a313"}, ] [[package]] @@ -1734,13 +1734,13 @@ tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] [[package]] name = "tomli" -version = "2.0.2" +version = "2.1.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, - {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, + {file = "tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391"}, + {file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"}, ] [[package]] @@ -1841,100 +1841,83 @@ test = ["websockets"] [[package]] name = "websockets" -version = "13.1" +version = "14.0" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee"}, - {file = "websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7"}, - {file = "websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6"}, - {file = "websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b"}, - {file = "websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa"}, - {file = "websockets-13.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700"}, - {file = "websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c"}, - {file = "websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0"}, - {file = "websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f"}, - {file = "websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe"}, - {file = "websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a"}, - {file = "websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19"}, - {file = "websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5"}, - {file = "websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd"}, - {file = "websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02"}, - {file = "websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7"}, - {file = "websockets-13.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096"}, - {file = "websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084"}, - {file = "websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3"}, - {file = "websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9"}, - {file = "websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f"}, - {file = "websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557"}, - {file = "websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc"}, - {file = "websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49"}, - {file = "websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd"}, - {file = "websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0"}, - {file = "websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6"}, - {file = "websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9"}, - {file = "websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68"}, - {file = "websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14"}, - {file = "websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf"}, - {file = "websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c"}, - {file = "websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3"}, - {file = "websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6"}, - {file = "websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708"}, - {file = "websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418"}, - {file = "websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a"}, - {file = "websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f"}, - {file = "websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5"}, - {file = "websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135"}, - {file = "websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2"}, - {file = "websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6"}, - {file = "websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d"}, - {file = "websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2"}, - {file = "websockets-13.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d"}, - {file = "websockets-13.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23"}, - {file = "websockets-13.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c"}, - {file = "websockets-13.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea"}, - {file = "websockets-13.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7"}, - {file = "websockets-13.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:035233b7531fb92a76beefcbf479504db8c72eb3bff41da55aecce3a0f729e54"}, - {file = "websockets-13.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db"}, - {file = "websockets-13.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295"}, - {file = "websockets-13.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96"}, - {file = "websockets-13.1-cp38-cp38-win32.whl", hash = "sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf"}, - {file = "websockets-13.1-cp38-cp38-win_amd64.whl", hash = "sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6"}, - {file = "websockets-13.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d"}, - {file = "websockets-13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7"}, - {file = "websockets-13.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a"}, - {file = "websockets-13.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa"}, - {file = "websockets-13.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa"}, - {file = "websockets-13.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2aad13a200e5934f5a6767492fb07151e1de1d6079c003ab31e1823733ae79"}, - {file = "websockets-13.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17"}, - {file = "websockets-13.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6"}, - {file = "websockets-13.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5"}, - {file = "websockets-13.1-cp39-cp39-win32.whl", hash = "sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c"}, - {file = "websockets-13.1-cp39-cp39-win_amd64.whl", hash = "sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d"}, - {file = "websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238"}, - {file = "websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5"}, - {file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9"}, - {file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6"}, - {file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a"}, - {file = "websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23"}, - {file = "websockets-13.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b"}, - {file = "websockets-13.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51"}, - {file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7"}, - {file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d"}, - {file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a11e38ad8922c7961447f35c7b17bffa15de4d17c70abd07bfbe12d6faa3e027"}, - {file = "websockets-13.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978"}, - {file = "websockets-13.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e"}, - {file = "websockets-13.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09"}, - {file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842"}, - {file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb"}, - {file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459bf774c754c35dbb487360b12c5727adab887f1622b8aed5755880a21c4a20"}, - {file = "websockets-13.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678"}, - {file = "websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f"}, - {file = "websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878"}, + {file = "websockets-14.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:064a72c0602c2d2c2586143561e0f179ef9b98e0825dc4a3d5cdf55a81898ed6"}, + {file = "websockets-14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9dc5a2726fd16c266d35838db086fa4e621bb049e3bbe498ab9d54ad5068f726"}, + {file = "websockets-14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e541e4c8983b118a584c306070878e7f9670b7781e04184b6e05f9fc92e8a0e"}, + {file = "websockets-14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23b13edb4df2d4e5d6dc747d83e6b244e267a6615ede90f18ef13dfb2b6feb87"}, + {file = "websockets-14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:288365a33049dae3065cdb2c2dd4b48df4b64839c565761c4f3f0c360460a561"}, + {file = "websockets-14.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79e2494047826a56f2951b2ada9dc139d2c3aff63122e86953cafe64ac0fde75"}, + {file = "websockets-14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5a5b76b47b62de16d26439d362b18d71394ca4376eb2c8838352be64b27ba8af"}, + {file = "websockets-14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7ed4111f305770e35070e49fbb9fbf757a9b6c9a31bb86d352eb4031d4aa976f"}, + {file = "websockets-14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9af48a2f4cc5e2e34cf69969079865100e418c27caa26c1e3369efcc20c81e17"}, + {file = "websockets-14.0-cp310-cp310-win32.whl", hash = "sha256:a97c10043bf74d7667be69383312007d54a507fac8fa101be492cc91e279d94d"}, + {file = "websockets-14.0-cp310-cp310-win_amd64.whl", hash = "sha256:5f86250ee98f6098479936b7d596418b6e4c919dfa156508e9d6ac5f8bfbe764"}, + {file = "websockets-14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3c12e6c1331ee8833fcb565c033f7eb4cb5642af37cef81211c222b617b170df"}, + {file = "websockets-14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:445a53bce8344e62df4ed9a22fdd1f06cad8e404ead64b2a1f19bd826c8dad1b"}, + {file = "websockets-14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3e4be641fed120790241ae15fde27374a62cadaadcc0bd2b4ce35790bd284fb6"}, + {file = "websockets-14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b886b6d14cd089396155e6beb2935268bf995057bf24c3e5fd609af55c584a03"}, + {file = "websockets-14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b8a85d62709a86a9a55d4720502e88968483ee7f365bd852b75935dec04e0d"}, + {file = "websockets-14.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08d62f438a591c016c5d4c79eaf9a8f7a85b6c3ea88793d676c00c930a41e775"}, + {file = "websockets-14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:189e9f074f2a77f7cf54634797b29be28116ee564ece421c7653030a2cef48f0"}, + {file = "websockets-14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b406f2387dbaf301996b7b2cf41519c1fbba7d5c9626406dd56f72075a60a00"}, + {file = "websockets-14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a3741f4394ba3d55a64949ee11ffdba19e2a2bdaa1319a96a7ab93bf8bd2b9b2"}, + {file = "websockets-14.0-cp311-cp311-win32.whl", hash = "sha256:b639ea88a46f4629645b398c9e7be0366c92e4910203a6314f78469f5e631dc5"}, + {file = "websockets-14.0-cp311-cp311-win_amd64.whl", hash = "sha256:715b238c1772ed28b98af8830df41c5d68941729e22384fe1433db495b1d5438"}, + {file = "websockets-14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f988f141a9be7a74d2e98d446b2f5411038bad14cdab80f9d1644b2329a71b48"}, + {file = "websockets-14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7fd212e7022c70b4f8246dee4449dde30ff50c7e8e1d61ac87b7879579badd03"}, + {file = "websockets-14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4c06f014fd8fa3827e5fd03ec012945e2139901f261fcc401e0622476cad9c5c"}, + {file = "websockets-14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fad8f03dc976e710db785abf9deb76eb259312fb54d77b568c73f0162cef96e"}, + {file = "websockets-14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6cff048a155024a580fee9f9a66b0ad9fc82683f6470c26eb76dd9280e6f459e"}, + {file = "websockets-14.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56ec8098dcc47817c8aee8037165f0fe30fec8efe543c66e0924781a4bfcbdfd"}, + {file = "websockets-14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee5fb667aec4ae723d40ada9854128df427b35b526c600cd352ca0240aad4dd7"}, + {file = "websockets-14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2752c98237057f27594a8393d498edd9db37e06abcfb99176d9cb6fb989dc883"}, + {file = "websockets-14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e9ff528498d9e5c543bee388023ca91870678ac50724d675853ba85b4f0a459e"}, + {file = "websockets-14.0-cp312-cp312-win32.whl", hash = "sha256:8982909857b09220ee31d9a45699fce26f8e5b94a10efa7fe07004d4f4200a33"}, + {file = "websockets-14.0-cp312-cp312-win_amd64.whl", hash = "sha256:61b60c2a07b6d25f7ce8cc0101d55fb0f1af388bec1eddfe0181085c2206e7b0"}, + {file = "websockets-14.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7cf000319db10a0cb5c7ce91bfd2a8699086b5cc0b5c5b83b92eec22a0448b2f"}, + {file = "websockets-14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bae3caf386d418e83b62e8c1c4cec1b13348fac43e530b9894d6c7c02d921b5"}, + {file = "websockets-14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8eb46ac94d5c131336dc997a568f5579501958b14a507e6aa4840f6d856da980"}, + {file = "websockets-14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12c345585b1da70cd27a298b0b9a81aa18da7a690672f771b427db59c632d8aa"}, + {file = "websockets-14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81758da7c76b4e2ddabc4a98a51f3c3aca8585a6d3a8662b5061613303bd5f68"}, + {file = "websockets-14.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4eae86193fd667667f35367d292b912685cb22c3f9f1dd6deaa3fdd713ab5976"}, + {file = "websockets-14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7078dd0eac3a1dccf2c6f474004dbe8a4e936dbd19d37bbfb6efa70c923ae04e"}, + {file = "websockets-14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2a418d596536a470f6f8e94cbb1fde66fe65e03d68c403eee0f2198b129e139a"}, + {file = "websockets-14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d66eeab61956e231f35659e6d5b66dc04a3d51e65f2b8f71862dc6a8ba710d1"}, + {file = "websockets-14.0-cp313-cp313-win32.whl", hash = "sha256:b24f7286a5c4e350284623cf708662f0881fe7bc1146c1a1fe7e6a9be01a8d6b"}, + {file = "websockets-14.0-cp313-cp313-win_amd64.whl", hash = "sha256:fb260539dd2b64e93c9f2c59caa70d36d2020fb8e26fa17f62459ad50ebf6c24"}, + {file = "websockets-14.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0913596e0072202be8729dab05266398b72ee57c4232f48d52fe2a0370d0b53f"}, + {file = "websockets-14.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f2e7710f3c468519f9d5b01a291c407f809f8f831e5a204b238e02447046d78"}, + {file = "websockets-14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ae0e14729038208711d2e2f769280621c22cd253e3dac00f809fa38c6ccb79d"}, + {file = "websockets-14.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4875d1c3ab3d1d9a9d8485dc1f4c2aaa63947824af03301911ea58d1e881e096"}, + {file = "websockets-14.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:678990bc5a1e4fa36e18d340d439079a21e6b8d249848b7066cad1a6cbd34b82"}, + {file = "websockets-14.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaf3b31f8343dcc6c20d068c10eb29325dd70f5dc321ebb5fbeaa280436e70e"}, + {file = "websockets-14.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:633bbda2d30bc695900f6a07de4e5d92a4e8e8d0d8a536bb3c2051bee4dc3856"}, + {file = "websockets-14.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1c4ca7cc5a02f909789dad259dffe61be4f38ffb26dc5e26ab2dca2c7d7c87de"}, + {file = "websockets-14.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5ade11f4939b885303d28b53d512e96e1a8ea8fbebedd6fef3e2e1afe633cc2a"}, + {file = "websockets-14.0-cp39-cp39-win32.whl", hash = "sha256:281b5ab9514eb241e347a46367a2374cb60cf8f420c4283948aa188f05e7810c"}, + {file = "websockets-14.0-cp39-cp39-win_amd64.whl", hash = "sha256:72fe11675685412917363481b79c56e68175e62352f84ca4788ac264f9ea6ed0"}, + {file = "websockets-14.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3f1a697262e28682222f18fae70eb0800dfa50c6eb96b0561c6beb83d6cf78ca"}, + {file = "websockets-14.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e0e543e0e81c55e68552bd3c081282721c710a6379a2a78e1ec793853479b25"}, + {file = "websockets-14.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2786c74cbcb0263fd541e4a075aa8c932bdcaa91e5bbb8649c65304799acdd64"}, + {file = "websockets-14.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:176b39547950ff3520728bd1eadd0fa02c68492a1fabca636bab7883dd390905"}, + {file = "websockets-14.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86626d560ceb9d846d128b9c7bd2d0f247dbb62fb49c386762d109583140bf48"}, + {file = "websockets-14.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ca447967131023e98fcb4867f05cf8584adb424b9108180b2414745a6ff41c31"}, + {file = "websockets-14.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c4eb304743ab285f8f057344d115259fbe31e42151b9aae7610db83d2a7379b1"}, + {file = "websockets-14.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:cc7dbe53276429b2ca511a04a3979ce27aa2088fdd28c119c6913dccdfd0e909"}, + {file = "websockets-14.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6dd785f7a521189b1233d3c86c0b66fb73d4769a1d253ce5b31081c5946f05f"}, + {file = "websockets-14.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:77697c303b874daf1c76d4e167cd5d6871c26964bc189e4bdb40427067d53a86"}, + {file = "websockets-14.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20979614e4d7266f15018c154255d35dfb9fc828fdf6b4924166b6728fed359f"}, + {file = "websockets-14.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3fb3d9e3940ea15b30404200e768e6111c3ee2956c60ceb001cae057961ab058"}, + {file = "websockets-14.0-py3-none-any.whl", hash = "sha256:1a3bca8cfb66614e23a65aa5d6b87190876ec6f3247094939f9db877db55319c"}, + {file = "websockets-14.0.tar.gz", hash = "sha256:be90aa6dab180fed523c0c10a6729ad16c9ba79067402d01a4d8aa7ce48d4084"}, ] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "ffeadfd8d9c2ccf3d7b6c07ee54e3b474084c2a3af98ad3351f6694f44393882" +content-hash = "6a48ecb388069d984aa377905940c16432f8516d3850537f6bb98f928febb510" diff --git a/pyproject.toml b/pyproject.toml index 1b80a9b..2402342 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ package-mode = false [tool.poetry.dependencies] python = "^3.10" -ops = "^2.17.0" +ops = "^2.15.0" [tool.poetry.group.charm-libs.dependencies] # data_platform_libs/v0/data_interfaces.py @@ -41,9 +41,10 @@ pytest-mock = "*" coverage = {extras = ["toml"], version = "*"} parameterized = "*" ops-scenario = "*" +ops = "^2.15.0" [tool.poetry.group.integration.dependencies] -ops = "^2.17.0" +ops = "^2.15.0" pytest = "*" pytest-github-secrets = {git = "https://github.com/canonical/data-platform-workflows", tag = "v22.0.0", subdirectory = "python/pytest_plugins/github_secrets"} pytest-asyncio = "*" diff --git a/src/charm.py b/src/charm.py index 1145cea..8242536 100755 --- a/src/charm.py +++ b/src/charm.py @@ -7,9 +7,18 @@ import logging import ops +from ops.charm import ( + LeaderElectedEvent, + RelationChangedEvent, + RelationCreatedEvent, + RelationDepartedEvent, + RelationJoinedEvent, +) from ops import StatusBase -from literals import DebugLevel, Status +from core.cluster import ClusterState +from literals import PEER_RELATION, SUBSTRATE, DebugLevel, Status +from managers.cluster import ClusterManager from workload import EtcdWorkload logger = logging.getLogger(__name__) @@ -18,14 +27,31 @@ class EtcdOperatorCharm(ops.CharmBase): """Charm the application.""" - def __init__(self, framework: ops.Framework): - super().__init__(framework) + def __init__(self, *args): + super().__init__(*args) self.workload = EtcdWorkload() + self.state = ClusterState(self, substrate=SUBSTRATE) + + # --- MANAGERS --- + self.cluster_manager = ClusterManager # --- CORE EVENTS --- - framework.observe(self.on.install, self._on_install) - framework.observe(self.on.start, self._on_start) - framework.observe(self.on.config_changed, self._on_config_changed) + 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[PEER_RELATION].relation_created, self._on_cluster_relation_created + ) + self.framework.observe( + self.on[PEER_RELATION].relation_joined, self._on_cluster_relation_joined + ) + self.framework.observe( + self.on[PEER_RELATION].relation_changed, self._on_cluster_relation_changed + ) + self.framework.observe( + self.on[PEER_RELATION].relation_departed, self._on_cluster_relation_departed + ) + self.framework.observe(self.on.leader_elected, self._on_leader_elected) def _on_install(self, event: ops.InstallEvent) -> None: """Handle install event.""" @@ -37,12 +63,34 @@ def _on_install(self, event: ops.InstallEvent) -> None: def _on_start(self, event: ops.StartEvent) -> None: """Handle start event.""" - self.unit.status = ops.ActiveStatus() + self._set_status(Status.ACTIVE) def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: """Handle config_changed event.""" pass + def _on_cluster_relation_created(self, event: RelationCreatedEvent) -> None: + """Handle event received by a new unit when joining the cluster relation.""" + pass + + def _on_cluster_relation_changed(self, event: RelationChangedEvent) -> None: + """Handle all events related to the cluster-peer relation.""" + self.state.unit_server.update(self.cluster_manager.get_host_mapping()) + + def _on_cluster_relation_departed(self, event: RelationDepartedEvent) -> None: + """Handle event received by a unit leaves the cluster relation.""" + pass + + def _on_cluster_relation_joined(self, event: RelationJoinedEvent) -> None: + """Handle event received by all units when a new unit joins the cluster relation.""" + pass + + def _on_leader_elected(self, event: LeaderElectedEvent) -> None: + """Handle all events in the 'cluster' peer relation.""" + if not self.state.peer_relation: + self._set_status(Status.NO_PEER_RELATION) + return + def _set_status(self, key: Status) -> None: """Set charm status.""" status: StatusBase = key.value.status diff --git a/src/core/cluster.py b/src/core/cluster.py new file mode 100644 index 0000000..236518e --- /dev/null +++ b/src/core/cluster.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Objects representing the state of EtcdOperatorCharm.""" + +import logging +from typing import TYPE_CHECKING + +from charms.data_platform_libs.v0.data_interfaces import DataPeerUnitData +from ops import Object, Relation + +from core.models import EtcdServer +from literals import PEER_RELATION, SUBSTRATES + +if TYPE_CHECKING: + from charm import EtcdOperatorCharm + +logger = logging.getLogger(__name__) + + +class ClusterState(Object): + """Global state object for the etcd cluster.""" + + def __init__(self, charm: "EtcdOperatorCharm", substrate: SUBSTRATES): + super().__init__(parent=charm, key="charm_state") + self.substrate: SUBSTRATES = substrate + self.peer_unit_interface = DataPeerUnitData(self.model, relation_name=PEER_RELATION) + + @property + def peer_relation(self) -> Relation | None: + """The cluster peer relation.""" + return self.model.get_relation(PEER_RELATION) + + @property + def unit_server(self) -> EtcdServer: + """The server state of the current running unit.""" + return EtcdServer( + relation=self.peer_relation, + data_interface=self.peer_unit_interface, + component=self.model.unit, + substrate=self.substrate, + ) diff --git a/src/core/models.py b/src/core/models.py new file mode 100644 index 0000000..15a0621 --- /dev/null +++ b/src/core/models.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""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, DataPeerUnitData +from ops.model import Application, Relation, Unit + +from literals import PEER_PORT, SERVER_PORT, SUBSTRATES + +logger = logging.getLogger(__name__) + + +class RelationState: + """Relation state object.""" + + def __init__( + self, + relation: Relation | None, + data_interface: Data, + component: Unit | Application | None, + substrate: SUBSTRATES, + ): + self.relation = relation + self.data_interface = data_interface + self.component = component + 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: + logger.warning( + f"Fields {list(items.keys())} were attempted to be written on the relation before it exists." + ) + return + + delete_fields = [key for key in items if not items[key]] + update_content = {k: items[k] for k in items if k not in delete_fields} + + self.relation_data.update(update_content) + + for field in delete_fields: + del self.relation_data[field] + + +class EtcdServer(RelationState): + """State/Relation data collection for a unit.""" + + def __init__( + self, + relation: Relation | None, + data_interface: DataPeerUnitData, + component: Unit, + substrate: SUBSTRATES, + ): + super().__init__(relation, data_interface, component, substrate) + self.unit = component + + @property + def unit_id(self) -> int: + """The id of the unit from the unit name.""" + return int(self.unit.name.split("/")[1]) + + @property + def hostname(self) -> str: + """The hostname for the unit.""" + return self.relation_data.get("hostname", "") + + @property + def ip(self) -> str: + """The IP address for the unit.""" + return self.relation_data.get("ip", "") + + @property + def peer_url(self) -> str: + """The peer connection endpoint for the Etcd server.""" + return f"http://{self.ip}:{SERVER_PORT}" + + @property + def client_url(self) -> str: + """The client connection endpoint for the Etcd server.""" + return f"http://{self.ip}:{PEER_PORT}" diff --git a/src/literals.py b/src/literals.py index 289c33a..8ce93c6 100644 --- a/src/literals.py +++ b/src/literals.py @@ -8,12 +8,18 @@ from enum import Enum from typing import Literal -from ops.model import BlockedStatus, StatusBase +from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, StatusBase SNAP_NAME = "etcd" SNAP_REVISION = 233 +PEER_RELATION = "etcd-cluster" +SERVER_PORT = 2379 +PEER_PORT = 2380 + DebugLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"] +SUBSTRATES = Literal["vm", "k8s"] +SUBSTRATE = "vm" @dataclass @@ -27,4 +33,6 @@ class StatusLevel: class Status(Enum): """Collection of possible statuses for the charm.""" + ACTIVE = StatusLevel(ActiveStatus(), "DEBUG") SERVICE_NOT_INSTALLED = StatusLevel(BlockedStatus("unable to install etcd snap"), "ERROR") + NO_PEER_RELATION = StatusLevel(MaintenanceStatus("no peer relation available"), "DEBUG") diff --git a/src/managers/cluster.py b/src/managers/cluster.py new file mode 100644 index 0000000..5152a63 --- /dev/null +++ b/src/managers/cluster.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Manager for all cluster/quorum/rbac related tasks.""" + +import logging +import socket + +logger = logging.getLogger(__name__) + + +class ClusterManager: + """Manage cluster members, quorum and authorization.""" + + def __init__(self): + pass + + def get_host_mapping(self) -> dict[str, str]: + """Collect hostname mapping for current unit. + + Returns: + Dict of string keys 'hostname', 'ip' and their values + """ + hostname = socket.gethostname() + + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0) + s.connect(("10.10.10.10", 1)) + ip = s.getsockname()[0] + s.close() + + return {"hostname": hostname, "ip": ip} diff --git a/src/workload.py b/src/workload.py index 43cdb39..266a76f 100644 --- a/src/workload.py +++ b/src/workload.py @@ -34,6 +34,7 @@ def install(self) -> bool: try: self.etcd.ensure(snap.SnapState.Present, revision=SNAP_REVISION) self.etcd.hold() + return True except snap.SnapError as e: logger.error(str(e)) return False diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 457d306..499cbdc 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -2,7 +2,6 @@ # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. -import unittest from unittest.mock import patch import ops @@ -11,18 +10,16 @@ from charm import EtcdOperatorCharm +def test_start(): + ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) + state_in = State() + state_out = ctx.run(ctx.on.start(), state_in) + assert state_out.unit_status == ops.ActiveStatus() -class TestCharm(unittest.TestCase): - def test_start(self): - ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) - state_in = State() - state_out = ctx.run(ctx.on.start(), state_in) - assert state_out.unit_status == ops.ActiveStatus() +def test_install_failure_blocked_status(): + ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) + state_in = State() - def test_install_failure_blocked_status(self): - ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) - state_in = State() - - with patch("workload.EtcdWorkload.install", return_value=False): - state_out = ctx.run(ctx.on.install(), state_in) - assert state_out.unit_status == ops.BlockedStatus("unable to install etcd snap") + with patch("workload.EtcdWorkload.install", return_value=False): + state_out = ctx.run(ctx.on.install(), state_in) + assert state_out.unit_status == ops.BlockedStatus("unable to install etcd snap") From 97d2cdeca0bebfb024feefce45b11fd1809d8c44 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Tue, 12 Nov 2024 16:22:48 +0000 Subject: [PATCH 05/50] fix linting --- src/charm.py | 2 +- tests/unit/test_charm.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/charm.py b/src/charm.py index 8242536..4f3bf6e 100755 --- a/src/charm.py +++ b/src/charm.py @@ -7,6 +7,7 @@ import logging import ops +from ops import StatusBase from ops.charm import ( LeaderElectedEvent, RelationChangedEvent, @@ -14,7 +15,6 @@ RelationDepartedEvent, RelationJoinedEvent, ) -from ops import StatusBase from core.cluster import ClusterState from literals import PEER_RELATION, SUBSTRATE, DebugLevel, Status diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 499cbdc..e82efb8 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -10,12 +10,14 @@ from charm import EtcdOperatorCharm + def test_start(): ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) state_in = State() state_out = ctx.run(ctx.on.start(), state_in) assert state_out.unit_status == ops.ActiveStatus() + def test_install_failure_blocked_status(): ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) state_in = State() From bf1c3a29246eba2275313126751fc27f7c7e7c40 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Tue, 12 Nov 2024 17:31:30 +0000 Subject: [PATCH 06/50] move base events from charm.py to `EtcdEvents` class --- metadata.yaml | 2 +- src/charm.py | 69 +++--------------------------- src/core/__init__.py | 0 src/events/__init__.py | 0 src/events/etcd.py | 90 ++++++++++++++++++++++++++++++++++++++++ src/managers/__init__.py | 0 6 files changed, 96 insertions(+), 65 deletions(-) create mode 100644 src/core/__init__.py create mode 100644 src/events/__init__.py create mode 100644 src/events/etcd.py create mode 100644 src/managers/__init__.py diff --git a/metadata.yaml b/metadata.yaml index ac362ed..02c533d 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -14,4 +14,4 @@ description: | peers: etcd-cluster: - interface: etcd_cluster \ No newline at end of file + interface: etcd_cluster diff --git a/src/charm.py b/src/charm.py index 4f3bf6e..3ab6f39 100755 --- a/src/charm.py +++ b/src/charm.py @@ -8,16 +8,10 @@ import ops from ops import StatusBase -from ops.charm import ( - LeaderElectedEvent, - RelationChangedEvent, - RelationCreatedEvent, - RelationDepartedEvent, - RelationJoinedEvent, -) from core.cluster import ClusterState -from literals import PEER_RELATION, SUBSTRATE, DebugLevel, Status +from events.etcd import EtcdEvents +from literals import SUBSTRATE, DebugLevel, Status from managers.cluster import ClusterManager from workload import EtcdWorkload @@ -35,63 +29,10 @@ def __init__(self, *args): # --- MANAGERS --- self.cluster_manager = ClusterManager - # --- CORE EVENTS --- - 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[PEER_RELATION].relation_created, self._on_cluster_relation_created - ) - self.framework.observe( - self.on[PEER_RELATION].relation_joined, self._on_cluster_relation_joined - ) - self.framework.observe( - self.on[PEER_RELATION].relation_changed, self._on_cluster_relation_changed - ) - self.framework.observe( - self.on[PEER_RELATION].relation_departed, self._on_cluster_relation_departed - ) - self.framework.observe(self.on.leader_elected, self._on_leader_elected) + # --- EVENT HANDLERS --- + self.etcd_events = EtcdEvents(self) - def _on_install(self, event: ops.InstallEvent) -> None: - """Handle install event.""" - install = self.workload.install() - if not install: - self._set_status(Status.SERVICE_NOT_INSTALLED) - event.defer() - return - - def _on_start(self, event: ops.StartEvent) -> None: - """Handle start event.""" - self._set_status(Status.ACTIVE) - - def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: - """Handle config_changed event.""" - pass - - def _on_cluster_relation_created(self, event: RelationCreatedEvent) -> None: - """Handle event received by a new unit when joining the cluster relation.""" - pass - - def _on_cluster_relation_changed(self, event: RelationChangedEvent) -> None: - """Handle all events related to the cluster-peer relation.""" - self.state.unit_server.update(self.cluster_manager.get_host_mapping()) - - def _on_cluster_relation_departed(self, event: RelationDepartedEvent) -> None: - """Handle event received by a unit leaves the cluster relation.""" - pass - - def _on_cluster_relation_joined(self, event: RelationJoinedEvent) -> None: - """Handle event received by all units when a new unit joins the cluster relation.""" - pass - - def _on_leader_elected(self, event: LeaderElectedEvent) -> None: - """Handle all events in the 'cluster' peer relation.""" - if not self.state.peer_relation: - self._set_status(Status.NO_PEER_RELATION) - return - - def _set_status(self, key: Status) -> None: + def set_status(self, key: Status) -> None: """Set charm status.""" status: StatusBase = key.value.status log_level: DebugLevel = key.value.log_level diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/events/__init__.py b/src/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/events/etcd.py b/src/events/etcd.py new file mode 100644 index 0000000..ca59769 --- /dev/null +++ b/src/events/etcd.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Limited +# See LICENSE file for licensing details. + +"""Etcd related event handlers.""" + +import logging +from typing import TYPE_CHECKING + +import ops +from ops import Object +from ops.charm import ( + LeaderElectedEvent, + RelationChangedEvent, + RelationCreatedEvent, + RelationDepartedEvent, + RelationJoinedEvent, +) + +from literals import PEER_RELATION, Status + +if TYPE_CHECKING: + from charm import EtcdOperatorCharm + +logger = logging.getLogger(__name__) + + +class EtcdEvents(Object): + """Handle all base and etcd related events.""" + + def __init__(self, charm: "EtcdOperatorCharm"): + super().__init__(charm, key="etcd_events") + self.charm = charm + + self.framework.observe(self.charm.on.install, self._on_install) + self.framework.observe(self.charm.on.start, self._on_start) + self.framework.observe(self.charm.on.config_changed, self._on_config_changed) + """ + self.framework.observe( + self.charm.on[PEER_RELATION].relation_created, self._on_cluster_relation_created + ) + self.framework.observe( + self.charm.on[PEER_RELATION].relation_joined, self._on_cluster_relation_joined + ) + self.framework.observe( + self.charm.on[PEER_RELATION].relation_changed, self._on_cluster_relation_changed + ) + self.framework.observe( + self.charm.on[PEER_RELATION].relation_departed, self._on_cluster_relation_departed + ) + """ + self.framework.observe(self.charm.on.leader_elected, self._on_leader_elected) + + def _on_install(self, event: ops.InstallEvent) -> None: + """Handle install event.""" + install = self.charm.workload.install() + if not install: + self.charm.set_status(Status.SERVICE_NOT_INSTALLED) + event.defer() + return + + def _on_start(self, event: ops.StartEvent) -> None: + """Handle start event.""" + self.charm.set_status(Status.ACTIVE) + + def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: + """Handle config_changed event.""" + pass + + def _on_cluster_relation_created(self, event: RelationCreatedEvent) -> None: + """Handle event received by a new unit when joining the cluster relation.""" + pass + + def _on_cluster_relation_changed(self, event: RelationChangedEvent) -> None: + """Handle all events related to the cluster-peer relation.""" + self.charm.state.unit_server.update(self.charm.cluster_manager.get_host_mapping()) + + def _on_cluster_relation_departed(self, event: RelationDepartedEvent) -> None: + """Handle event received by a unit leaves the cluster relation.""" + pass + + def _on_cluster_relation_joined(self, event: RelationJoinedEvent) -> None: + """Handle event received by all units when a new unit joins the cluster relation.""" + pass + + def _on_leader_elected(self, event: LeaderElectedEvent) -> None: + """Handle all events in the 'cluster' peer relation.""" + if not self.charm.state.peer_relation: + self.charm.set_status(Status.NO_PEER_RELATION) + return diff --git a/src/managers/__init__.py b/src/managers/__init__.py new file mode 100644 index 0000000..e69de29 From 723d2c06df1d591a6ade1fa20c94ed6c282db7ca Mon Sep 17 00:00:00 2001 From: reneradoi Date: Wed, 13 Nov 2024 10:41:23 +0000 Subject: [PATCH 07/50] temporarily move population peer relation unit data to start-hook --- src/charm.py | 2 +- src/events/etcd.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/charm.py b/src/charm.py index 3ab6f39..0a43b32 100755 --- a/src/charm.py +++ b/src/charm.py @@ -27,7 +27,7 @@ def __init__(self, *args): self.state = ClusterState(self, substrate=SUBSTRATE) # --- MANAGERS --- - self.cluster_manager = ClusterManager + self.cluster_manager = ClusterManager() # --- EVENT HANDLERS --- self.etcd_events = EtcdEvents(self) diff --git a/src/events/etcd.py b/src/events/etcd.py index ca59769..f747c87 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -61,6 +61,8 @@ def _on_install(self, event: ops.InstallEvent) -> None: def _on_start(self, event: ops.StartEvent) -> None: """Handle start event.""" + self.charm.state.unit_server.update(self.charm.cluster_manager.get_host_mapping()) + self.charm.set_status(Status.ACTIVE) def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: @@ -73,7 +75,7 @@ def _on_cluster_relation_created(self, event: RelationCreatedEvent) -> None: def _on_cluster_relation_changed(self, event: RelationChangedEvent) -> None: """Handle all events related to the cluster-peer relation.""" - self.charm.state.unit_server.update(self.charm.cluster_manager.get_host_mapping()) + pass def _on_cluster_relation_departed(self, event: RelationDepartedEvent) -> None: """Handle event received by a unit leaves the cluster relation.""" From a1d9d755d538d252a2e81be4c99bf28c49ce191d Mon Sep 17 00:00:00 2001 From: reneradoi Date: Wed, 13 Nov 2024 10:42:03 +0000 Subject: [PATCH 08/50] add peer relation to unit test --- tests/unit/test_charm.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index e82efb8..966c227 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -6,14 +6,15 @@ import ops import ops.testing -from scenario import Context, State +from scenario import Context, State, Relation from charm import EtcdOperatorCharm def test_start(): ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) - state_in = State() + relation = Relation(id=1, endpoint="etcd-cluster", remote_units_data={1: {}}) + state_in = State(relations=[relation]) state_out = ctx.run(ctx.on.start(), state_in) assert state_out.unit_status == ops.ActiveStatus() From 0396f3e76b7a76b40f98bf696f6d41e6290decb8 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Wed, 13 Nov 2024 12:40:55 +0000 Subject: [PATCH 09/50] add alive-check for snap, add handler for `update_status` event --- src/core/__init__.py | 1 + src/core/workload.py | 5 +++++ src/events/__init__.py | 1 + src/events/etcd.py | 13 ++++++++++++- src/literals.py | 2 ++ src/managers/__init__.py | 1 + src/workload.py | 14 ++++++++++++-- tests/unit/test_charm.py | 32 ++++++++++++++++++++++++-------- 8 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/core/__init__.py b/src/core/__init__.py index e69de29..3ae509b 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -0,0 +1 @@ +"""Package containing core logic.""" diff --git a/src/core/workload.py b/src/core/workload.py index aae7628..a403190 100644 --- a/src/core/workload.py +++ b/src/core/workload.py @@ -14,3 +14,8 @@ class WorkloadBase(ABC): def start(self) -> None: """Start the workload service.""" pass + + @abstractmethod + def alive(self) -> bool: + """Check if the workload is running.""" + pass diff --git a/src/events/__init__.py b/src/events/__init__.py index e69de29..c16d205 100644 --- a/src/events/__init__.py +++ b/src/events/__init__.py @@ -0,0 +1 @@ +"""Package containing event handlers.""" diff --git a/src/events/etcd.py b/src/events/etcd.py index f747c87..b181df6 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -50,6 +50,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) def _on_install(self, event: ops.InstallEvent) -> None: """Handle install event.""" @@ -63,7 +64,12 @@ def _on_start(self, event: ops.StartEvent) -> None: """Handle start event.""" self.charm.state.unit_server.update(self.charm.cluster_manager.get_host_mapping()) - self.charm.set_status(Status.ACTIVE) + self.charm.workload.start() + + if self.charm.workload.alive(): + self.charm.set_status(Status.ACTIVE) + else: + self.charm.set_status(Status.SERVICE_NOT_RUNNING) def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: """Handle config_changed event.""" @@ -90,3 +96,8 @@ def _on_leader_elected(self, event: LeaderElectedEvent) -> None: if not self.charm.state.peer_relation: self.charm.set_status(Status.NO_PEER_RELATION) return + + 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) diff --git a/src/literals.py b/src/literals.py index 8ce93c6..6b4f95c 100644 --- a/src/literals.py +++ b/src/literals.py @@ -12,6 +12,7 @@ SNAP_NAME = "etcd" SNAP_REVISION = 233 +SNAP_SERVICE = "etcd.etcd" PEER_RELATION = "etcd-cluster" SERVER_PORT = 2379 @@ -35,4 +36,5 @@ class Status(Enum): ACTIVE = StatusLevel(ActiveStatus(), "DEBUG") 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/__init__.py b/src/managers/__init__.py index e69de29..1928970 100644 --- a/src/managers/__init__.py +++ b/src/managers/__init__.py @@ -0,0 +1 @@ +"""Package containing managers for business logic.""" diff --git a/src/workload.py b/src/workload.py index 266a76f..e0742ea 100644 --- a/src/workload.py +++ b/src/workload.py @@ -10,7 +10,7 @@ from typing_extensions import override from core.workload import WorkloadBase -from literals import SNAP_NAME, SNAP_REVISION +from literals import SNAP_NAME, SNAP_REVISION, SNAP_SERVICE logger = logging.getLogger(__name__) @@ -23,7 +23,10 @@ def __init__(self): @override def start(self) -> None: - pass + try: + self.etcd.start(services=[SNAP_SERVICE]) + except snap.SnapError as e: + logger.exception(str(e)) def install(self) -> bool: """Install the etcd snap from the snap store. @@ -38,3 +41,10 @@ def install(self) -> bool: except snap.SnapError as e: logger.error(str(e)) return False + + @override + def alive(self) -> bool: + try: + return bool(self.etcd.services[SNAP_SERVICE]["active"]) + except KeyError: + return False diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 966c227..895aeda 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -11,14 +11,6 @@ from charm import EtcdOperatorCharm -def test_start(): - ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) - relation = Relation(id=1, endpoint="etcd-cluster", remote_units_data={1: {}}) - state_in = State(relations=[relation]) - state_out = ctx.run(ctx.on.start(), state_in) - assert state_out.unit_status == ops.ActiveStatus() - - def test_install_failure_blocked_status(): ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) state_in = State() @@ -26,3 +18,27 @@ def test_install_failure_blocked_status(): with patch("workload.EtcdWorkload.install", return_value=False): state_out = ctx.run(ctx.on.install(), state_in) assert state_out.unit_status == ops.BlockedStatus("unable to install etcd snap") + + +def test_start(): + ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) +# relation = Relation(id=1, endpoint="etcd-cluster", remote_units_data={1: {}}) +# state_in = State(relations=[relation]) + state_in = State() + + with patch("workload.EtcdWorkload.alive", return_value=True): + state_out = ctx.run(ctx.on.start(), state_in) + assert state_out.unit_status == ops.ActiveStatus() + + with patch("workload.EtcdWorkload.alive", return_value=False): + state_out = ctx.run(ctx.on.start(), state_in) + assert state_out.unit_status == ops.BlockedStatus("etcd service not running") + + +def test_update_status(): + ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) + state_in = State() + + with patch("workload.EtcdWorkload.alive", return_value=False): + state_out = ctx.run(ctx.on.update_status(), state_in) + assert state_out.unit_status == ops.BlockedStatus("etcd service not running") From 6531227511c031e2febb433fe829f95adbb4b2fd Mon Sep 17 00:00:00 2001 From: reneradoi Date: Thu, 14 Nov 2024 11:05:55 +0000 Subject: [PATCH 10/50] fix unit testing --- poetry.lock | 161 ++++++++++++++++++++++----------------- pyproject.toml | 9 ++- src/events/etcd.py | 2 - tests/unit/test_charm.py | 18 ++--- 4 files changed, 103 insertions(+), 87 deletions(-) diff --git a/poetry.lock b/poetry.lock index 927bf95..b11b032 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1841,83 +1841,100 @@ test = ["websockets"] [[package]] name = "websockets" -version = "14.0" +version = "13.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false -python-versions = ">=3.9" +python-versions = ">=3.8" files = [ - {file = "websockets-14.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:064a72c0602c2d2c2586143561e0f179ef9b98e0825dc4a3d5cdf55a81898ed6"}, - {file = "websockets-14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9dc5a2726fd16c266d35838db086fa4e621bb049e3bbe498ab9d54ad5068f726"}, - {file = "websockets-14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e541e4c8983b118a584c306070878e7f9670b7781e04184b6e05f9fc92e8a0e"}, - {file = "websockets-14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23b13edb4df2d4e5d6dc747d83e6b244e267a6615ede90f18ef13dfb2b6feb87"}, - {file = "websockets-14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:288365a33049dae3065cdb2c2dd4b48df4b64839c565761c4f3f0c360460a561"}, - {file = "websockets-14.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79e2494047826a56f2951b2ada9dc139d2c3aff63122e86953cafe64ac0fde75"}, - {file = "websockets-14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5a5b76b47b62de16d26439d362b18d71394ca4376eb2c8838352be64b27ba8af"}, - {file = "websockets-14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7ed4111f305770e35070e49fbb9fbf757a9b6c9a31bb86d352eb4031d4aa976f"}, - {file = "websockets-14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9af48a2f4cc5e2e34cf69969079865100e418c27caa26c1e3369efcc20c81e17"}, - {file = "websockets-14.0-cp310-cp310-win32.whl", hash = "sha256:a97c10043bf74d7667be69383312007d54a507fac8fa101be492cc91e279d94d"}, - {file = "websockets-14.0-cp310-cp310-win_amd64.whl", hash = "sha256:5f86250ee98f6098479936b7d596418b6e4c919dfa156508e9d6ac5f8bfbe764"}, - {file = "websockets-14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3c12e6c1331ee8833fcb565c033f7eb4cb5642af37cef81211c222b617b170df"}, - {file = "websockets-14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:445a53bce8344e62df4ed9a22fdd1f06cad8e404ead64b2a1f19bd826c8dad1b"}, - {file = "websockets-14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3e4be641fed120790241ae15fde27374a62cadaadcc0bd2b4ce35790bd284fb6"}, - {file = "websockets-14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b886b6d14cd089396155e6beb2935268bf995057bf24c3e5fd609af55c584a03"}, - {file = "websockets-14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b8a85d62709a86a9a55d4720502e88968483ee7f365bd852b75935dec04e0d"}, - {file = "websockets-14.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08d62f438a591c016c5d4c79eaf9a8f7a85b6c3ea88793d676c00c930a41e775"}, - {file = "websockets-14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:189e9f074f2a77f7cf54634797b29be28116ee564ece421c7653030a2cef48f0"}, - {file = "websockets-14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b406f2387dbaf301996b7b2cf41519c1fbba7d5c9626406dd56f72075a60a00"}, - {file = "websockets-14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a3741f4394ba3d55a64949ee11ffdba19e2a2bdaa1319a96a7ab93bf8bd2b9b2"}, - {file = "websockets-14.0-cp311-cp311-win32.whl", hash = "sha256:b639ea88a46f4629645b398c9e7be0366c92e4910203a6314f78469f5e631dc5"}, - {file = "websockets-14.0-cp311-cp311-win_amd64.whl", hash = "sha256:715b238c1772ed28b98af8830df41c5d68941729e22384fe1433db495b1d5438"}, - {file = "websockets-14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f988f141a9be7a74d2e98d446b2f5411038bad14cdab80f9d1644b2329a71b48"}, - {file = "websockets-14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7fd212e7022c70b4f8246dee4449dde30ff50c7e8e1d61ac87b7879579badd03"}, - {file = "websockets-14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4c06f014fd8fa3827e5fd03ec012945e2139901f261fcc401e0622476cad9c5c"}, - {file = "websockets-14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fad8f03dc976e710db785abf9deb76eb259312fb54d77b568c73f0162cef96e"}, - {file = "websockets-14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6cff048a155024a580fee9f9a66b0ad9fc82683f6470c26eb76dd9280e6f459e"}, - {file = "websockets-14.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56ec8098dcc47817c8aee8037165f0fe30fec8efe543c66e0924781a4bfcbdfd"}, - {file = "websockets-14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee5fb667aec4ae723d40ada9854128df427b35b526c600cd352ca0240aad4dd7"}, - {file = "websockets-14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2752c98237057f27594a8393d498edd9db37e06abcfb99176d9cb6fb989dc883"}, - {file = "websockets-14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e9ff528498d9e5c543bee388023ca91870678ac50724d675853ba85b4f0a459e"}, - {file = "websockets-14.0-cp312-cp312-win32.whl", hash = "sha256:8982909857b09220ee31d9a45699fce26f8e5b94a10efa7fe07004d4f4200a33"}, - {file = "websockets-14.0-cp312-cp312-win_amd64.whl", hash = "sha256:61b60c2a07b6d25f7ce8cc0101d55fb0f1af388bec1eddfe0181085c2206e7b0"}, - {file = "websockets-14.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7cf000319db10a0cb5c7ce91bfd2a8699086b5cc0b5c5b83b92eec22a0448b2f"}, - {file = "websockets-14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bae3caf386d418e83b62e8c1c4cec1b13348fac43e530b9894d6c7c02d921b5"}, - {file = "websockets-14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8eb46ac94d5c131336dc997a568f5579501958b14a507e6aa4840f6d856da980"}, - {file = "websockets-14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12c345585b1da70cd27a298b0b9a81aa18da7a690672f771b427db59c632d8aa"}, - {file = "websockets-14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81758da7c76b4e2ddabc4a98a51f3c3aca8585a6d3a8662b5061613303bd5f68"}, - {file = "websockets-14.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4eae86193fd667667f35367d292b912685cb22c3f9f1dd6deaa3fdd713ab5976"}, - {file = "websockets-14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7078dd0eac3a1dccf2c6f474004dbe8a4e936dbd19d37bbfb6efa70c923ae04e"}, - {file = "websockets-14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2a418d596536a470f6f8e94cbb1fde66fe65e03d68c403eee0f2198b129e139a"}, - {file = "websockets-14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d66eeab61956e231f35659e6d5b66dc04a3d51e65f2b8f71862dc6a8ba710d1"}, - {file = "websockets-14.0-cp313-cp313-win32.whl", hash = "sha256:b24f7286a5c4e350284623cf708662f0881fe7bc1146c1a1fe7e6a9be01a8d6b"}, - {file = "websockets-14.0-cp313-cp313-win_amd64.whl", hash = "sha256:fb260539dd2b64e93c9f2c59caa70d36d2020fb8e26fa17f62459ad50ebf6c24"}, - {file = "websockets-14.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0913596e0072202be8729dab05266398b72ee57c4232f48d52fe2a0370d0b53f"}, - {file = "websockets-14.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f2e7710f3c468519f9d5b01a291c407f809f8f831e5a204b238e02447046d78"}, - {file = "websockets-14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ae0e14729038208711d2e2f769280621c22cd253e3dac00f809fa38c6ccb79d"}, - {file = "websockets-14.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4875d1c3ab3d1d9a9d8485dc1f4c2aaa63947824af03301911ea58d1e881e096"}, - {file = "websockets-14.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:678990bc5a1e4fa36e18d340d439079a21e6b8d249848b7066cad1a6cbd34b82"}, - {file = "websockets-14.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaf3b31f8343dcc6c20d068c10eb29325dd70f5dc321ebb5fbeaa280436e70e"}, - {file = "websockets-14.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:633bbda2d30bc695900f6a07de4e5d92a4e8e8d0d8a536bb3c2051bee4dc3856"}, - {file = "websockets-14.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1c4ca7cc5a02f909789dad259dffe61be4f38ffb26dc5e26ab2dca2c7d7c87de"}, - {file = "websockets-14.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5ade11f4939b885303d28b53d512e96e1a8ea8fbebedd6fef3e2e1afe633cc2a"}, - {file = "websockets-14.0-cp39-cp39-win32.whl", hash = "sha256:281b5ab9514eb241e347a46367a2374cb60cf8f420c4283948aa188f05e7810c"}, - {file = "websockets-14.0-cp39-cp39-win_amd64.whl", hash = "sha256:72fe11675685412917363481b79c56e68175e62352f84ca4788ac264f9ea6ed0"}, - {file = "websockets-14.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3f1a697262e28682222f18fae70eb0800dfa50c6eb96b0561c6beb83d6cf78ca"}, - {file = "websockets-14.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e0e543e0e81c55e68552bd3c081282721c710a6379a2a78e1ec793853479b25"}, - {file = "websockets-14.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2786c74cbcb0263fd541e4a075aa8c932bdcaa91e5bbb8649c65304799acdd64"}, - {file = "websockets-14.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:176b39547950ff3520728bd1eadd0fa02c68492a1fabca636bab7883dd390905"}, - {file = "websockets-14.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86626d560ceb9d846d128b9c7bd2d0f247dbb62fb49c386762d109583140bf48"}, - {file = "websockets-14.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ca447967131023e98fcb4867f05cf8584adb424b9108180b2414745a6ff41c31"}, - {file = "websockets-14.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c4eb304743ab285f8f057344d115259fbe31e42151b9aae7610db83d2a7379b1"}, - {file = "websockets-14.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:cc7dbe53276429b2ca511a04a3979ce27aa2088fdd28c119c6913dccdfd0e909"}, - {file = "websockets-14.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6dd785f7a521189b1233d3c86c0b66fb73d4769a1d253ce5b31081c5946f05f"}, - {file = "websockets-14.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:77697c303b874daf1c76d4e167cd5d6871c26964bc189e4bdb40427067d53a86"}, - {file = "websockets-14.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20979614e4d7266f15018c154255d35dfb9fc828fdf6b4924166b6728fed359f"}, - {file = "websockets-14.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3fb3d9e3940ea15b30404200e768e6111c3ee2956c60ceb001cae057961ab058"}, - {file = "websockets-14.0-py3-none-any.whl", hash = "sha256:1a3bca8cfb66614e23a65aa5d6b87190876ec6f3247094939f9db877db55319c"}, - {file = "websockets-14.0.tar.gz", hash = "sha256:be90aa6dab180fed523c0c10a6729ad16c9ba79067402d01a4d8aa7ce48d4084"}, + {file = "websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee"}, + {file = "websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7"}, + {file = "websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6"}, + {file = "websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b"}, + {file = "websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa"}, + {file = "websockets-13.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700"}, + {file = "websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c"}, + {file = "websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0"}, + {file = "websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f"}, + {file = "websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe"}, + {file = "websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a"}, + {file = "websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19"}, + {file = "websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5"}, + {file = "websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd"}, + {file = "websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02"}, + {file = "websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7"}, + {file = "websockets-13.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096"}, + {file = "websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084"}, + {file = "websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3"}, + {file = "websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9"}, + {file = "websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f"}, + {file = "websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557"}, + {file = "websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc"}, + {file = "websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49"}, + {file = "websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd"}, + {file = "websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0"}, + {file = "websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6"}, + {file = "websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9"}, + {file = "websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68"}, + {file = "websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14"}, + {file = "websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf"}, + {file = "websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c"}, + {file = "websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3"}, + {file = "websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6"}, + {file = "websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708"}, + {file = "websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418"}, + {file = "websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a"}, + {file = "websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f"}, + {file = "websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5"}, + {file = "websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135"}, + {file = "websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2"}, + {file = "websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6"}, + {file = "websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d"}, + {file = "websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2"}, + {file = "websockets-13.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d"}, + {file = "websockets-13.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23"}, + {file = "websockets-13.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c"}, + {file = "websockets-13.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea"}, + {file = "websockets-13.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7"}, + {file = "websockets-13.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:035233b7531fb92a76beefcbf479504db8c72eb3bff41da55aecce3a0f729e54"}, + {file = "websockets-13.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db"}, + {file = "websockets-13.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295"}, + {file = "websockets-13.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96"}, + {file = "websockets-13.1-cp38-cp38-win32.whl", hash = "sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf"}, + {file = "websockets-13.1-cp38-cp38-win_amd64.whl", hash = "sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6"}, + {file = "websockets-13.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d"}, + {file = "websockets-13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7"}, + {file = "websockets-13.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a"}, + {file = "websockets-13.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa"}, + {file = "websockets-13.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa"}, + {file = "websockets-13.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2aad13a200e5934f5a6767492fb07151e1de1d6079c003ab31e1823733ae79"}, + {file = "websockets-13.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17"}, + {file = "websockets-13.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6"}, + {file = "websockets-13.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5"}, + {file = "websockets-13.1-cp39-cp39-win32.whl", hash = "sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c"}, + {file = "websockets-13.1-cp39-cp39-win_amd64.whl", hash = "sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d"}, + {file = "websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238"}, + {file = "websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5"}, + {file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9"}, + {file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6"}, + {file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a"}, + {file = "websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23"}, + {file = "websockets-13.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b"}, + {file = "websockets-13.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51"}, + {file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7"}, + {file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d"}, + {file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a11e38ad8922c7961447f35c7b17bffa15de4d17c70abd07bfbe12d6faa3e027"}, + {file = "websockets-13.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978"}, + {file = "websockets-13.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e"}, + {file = "websockets-13.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09"}, + {file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842"}, + {file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb"}, + {file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459bf774c754c35dbb487360b12c5727adab887f1622b8aed5755880a21c4a20"}, + {file = "websockets-13.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678"}, + {file = "websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f"}, + {file = "websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878"}, ] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "6a48ecb388069d984aa377905940c16432f8516d3850537f6bb98f928febb510" +content-hash = "30188db9a37f289d25b9e4ad80bfed302b0eaf25ca17bd51c3d3233a4173fc6e" diff --git a/pyproject.toml b/pyproject.toml index 2402342..72cd047 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ package-mode = false [tool.poetry.dependencies] python = "^3.10" -ops = "^2.15.0" +ops = "^2.17.0" [tool.poetry.group.charm-libs.dependencies] # data_platform_libs/v0/data_interfaces.py @@ -41,10 +41,10 @@ pytest-mock = "*" coverage = {extras = ["toml"], version = "*"} parameterized = "*" ops-scenario = "*" -ops = "^2.15.0" +ops = "^2.17.0" [tool.poetry.group.integration.dependencies] -ops = "^2.15.0" +ops = "^2.17.0" pytest = "*" pytest-github-secrets = {git = "https://github.com/canonical/data-platform-workflows", tag = "v22.0.0", subdirectory = "python/pytest_plugins/github_secrets"} pytest-asyncio = "*" @@ -54,6 +54,9 @@ pytest-operator-groups = {git = "https://github.com/canonical/data-platform-work juju = "3.5.2" allure-pytest = "*" allure-pytest-collection-report = {git = "https://github.com/canonical/data-platform-workflows", tag = "v22.0.0", subdirectory = "python/pytest_plugins/allure_pytest_collection_report"} +# pin websockets to <14.0 because of breaking changes in this version +# see also: https://github.com/juju/python-libjuju/issues/1184 +websockets = "<14.0" [tool.coverage.run] branch = true diff --git a/src/events/etcd.py b/src/events/etcd.py index b181df6..5287900 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -35,7 +35,6 @@ def __init__(self, charm: "EtcdOperatorCharm"): self.framework.observe(self.charm.on.install, self._on_install) self.framework.observe(self.charm.on.start, self._on_start) self.framework.observe(self.charm.on.config_changed, self._on_config_changed) - """ self.framework.observe( self.charm.on[PEER_RELATION].relation_created, self._on_cluster_relation_created ) @@ -48,7 +47,6 @@ def __init__(self, charm: "EtcdOperatorCharm"): self.framework.observe( self.charm.on[PEER_RELATION].relation_departed, self._on_cluster_relation_departed ) - """ self.framework.observe(self.charm.on.leader_elected, self._on_leader_elected) self.framework.observe(self.charm.on.update_status, self._on_update_status) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 895aeda..5860959 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -5,15 +5,14 @@ from unittest.mock import patch import ops -import ops.testing -from scenario import Context, State, Relation +from ops import testing from charm import EtcdOperatorCharm def test_install_failure_blocked_status(): - ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) - state_in = State() + ctx = testing.Context(EtcdOperatorCharm) + state_in = testing.State() with patch("workload.EtcdWorkload.install", return_value=False): state_out = ctx.run(ctx.on.install(), state_in) @@ -21,10 +20,9 @@ def test_install_failure_blocked_status(): def test_start(): - ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) -# relation = Relation(id=1, endpoint="etcd-cluster", remote_units_data={1: {}}) -# state_in = State(relations=[relation]) - state_in = State() + ctx = testing.Context(EtcdOperatorCharm) + relation = testing.PeerRelation(id=1, endpoint="etcd-cluster", peers_data={1: {}}) + state_in = testing.State(relations={relation}) with patch("workload.EtcdWorkload.alive", return_value=True): state_out = ctx.run(ctx.on.start(), state_in) @@ -36,8 +34,8 @@ def test_start(): def test_update_status(): - ctx = Context(EtcdOperatorCharm, meta={"name": "my-charm"}) - state_in = State() + ctx = testing.Context(EtcdOperatorCharm) + state_in = testing.State() with patch("workload.EtcdWorkload.alive", return_value=False): state_out = ctx.run(ctx.on.update_status(), state_in) From a4939b8be524bc8af56484fafc0b11e9abbe1f7a Mon Sep 17 00:00:00 2001 From: reneradoi Date: Thu, 14 Nov 2024 11:24:05 +0000 Subject: [PATCH 11/50] decouple cluster manager from charm.py, should be instantiated by event handler --- src/charm.py | 4 ---- src/events/etcd.py | 8 +++++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/charm.py b/src/charm.py index 0a43b32..4d0bbc1 100755 --- a/src/charm.py +++ b/src/charm.py @@ -12,7 +12,6 @@ from core.cluster import ClusterState from events.etcd import EtcdEvents from literals import SUBSTRATE, DebugLevel, Status -from managers.cluster import ClusterManager from workload import EtcdWorkload logger = logging.getLogger(__name__) @@ -26,9 +25,6 @@ def __init__(self, *args): self.workload = EtcdWorkload() self.state = ClusterState(self, substrate=SUBSTRATE) - # --- MANAGERS --- - self.cluster_manager = ClusterManager() - # --- EVENT HANDLERS --- self.etcd_events = EtcdEvents(self) diff --git a/src/events/etcd.py b/src/events/etcd.py index 5287900..e7a73f8 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -18,6 +18,7 @@ ) from literals import PEER_RELATION, Status +from managers.cluster import ClusterManager if TYPE_CHECKING: from charm import EtcdOperatorCharm @@ -32,6 +33,11 @@ def __init__(self, charm: "EtcdOperatorCharm"): super().__init__(charm, key="etcd_events") self.charm = charm + # --- MANAGERS --- + self.cluster_manager = ClusterManager() + + # --- Core etcd charm events --- + self.framework.observe(self.charm.on.install, self._on_install) self.framework.observe(self.charm.on.start, self._on_start) self.framework.observe(self.charm.on.config_changed, self._on_config_changed) @@ -60,7 +66,7 @@ def _on_install(self, event: ops.InstallEvent) -> None: def _on_start(self, event: ops.StartEvent) -> None: """Handle start event.""" - self.charm.state.unit_server.update(self.charm.cluster_manager.get_host_mapping()) + self.charm.state.unit_server.update(self.cluster_manager.get_host_mapping()) self.charm.workload.start() From 9c0e820651c7c93448ecb6e4017a13d99dc024f5 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Thu, 14 Nov 2024 16:55:55 +0000 Subject: [PATCH 12/50] implement basic ConfigManager --- src/core/models.py | 5 ++ src/core/workload.py | 5 ++ src/events/etcd.py | 8 ++- src/literals.py | 5 +- src/managers/config.py | 107 +++++++++++++++++++++++++++++++++++++++ src/workload.py | 8 +++ tests/unit/test_charm.py | 26 ++++++++-- 7 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 src/managers/config.py diff --git a/src/core/models.py b/src/core/models.py index 15a0621..366fb56 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -71,6 +71,11 @@ def unit_id(self) -> int: """The id of the unit from the unit name.""" return int(self.unit.name.split("/")[1]) + @property + def unit_name(self) -> str: + """The id of the unit from the unit name.""" + return self.unit.name + @property def hostname(self) -> str: """The hostname for the unit.""" diff --git a/src/core/workload.py b/src/core/workload.py index a403190..06f8130 100644 --- a/src/core/workload.py +++ b/src/core/workload.py @@ -19,3 +19,8 @@ def start(self) -> None: def alive(self) -> bool: """Check if the workload is running.""" pass + + @abstractmethod + def write(self, content: str, path: str) -> None: + """Write content to a file.""" + pass diff --git a/src/events/etcd.py b/src/events/etcd.py index e7a73f8..2581712 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -19,6 +19,7 @@ from literals import PEER_RELATION, Status from managers.cluster import ClusterManager +from managers.config import ConfigManager if TYPE_CHECKING: from charm import EtcdOperatorCharm @@ -35,6 +36,9 @@ def __init__(self, charm: "EtcdOperatorCharm"): # --- MANAGERS --- self.cluster_manager = ClusterManager() + self.config_manager = ConfigManager( + state=self.charm.state, workload=self.charm.workload, config=self.charm.config + ) # --- Core etcd charm events --- @@ -66,7 +70,7 @@ def _on_install(self, event: ops.InstallEvent) -> None: def _on_start(self, event: ops.StartEvent) -> None: """Handle start event.""" - self.charm.state.unit_server.update(self.cluster_manager.get_host_mapping()) + self.config_manager.set_config_properties() self.charm.workload.start() @@ -81,7 +85,7 @@ def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: def _on_cluster_relation_created(self, event: RelationCreatedEvent) -> None: """Handle event received by a new unit when joining the cluster relation.""" - pass + self.charm.state.unit_server.update(self.cluster_manager.get_host_mapping()) def _on_cluster_relation_changed(self, event: RelationChangedEvent) -> None: """Handle all events related to the cluster-peer relation.""" diff --git a/src/literals.py b/src/literals.py index 6b4f95c..35af22d 100644 --- a/src/literals.py +++ b/src/literals.py @@ -12,7 +12,10 @@ SNAP_NAME = "etcd" SNAP_REVISION = 233 -SNAP_SERVICE = "etcd.etcd" +SNAP_SERVICE = "etcd" +# this path will be updated when we switch to charmed-etcd snap +# it's the current config path for the legacy-etcd snap +CONFIG_PATH = "/var/snap/etcd/common/etcd.conf.yml" PEER_RELATION = "etcd-cluster" SERVER_PORT = 2379 diff --git a/src/managers/config.py b/src/managers/config.py new file mode 100644 index 0000000..0cd6dad --- /dev/null +++ b/src/managers/config.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Manager for handling configuration building + writing.""" + +import logging + +from ops.model import ConfigData + +from core.cluster import ClusterState +from core.workload import WorkloadBase +from literals import CONFIG_PATH + +logger = logging.getLogger(__name__) + +DYNAMIC_PROPERTIES = """ +data-dir: -> get from snap +wal-dir: -> get from snap +listen-peer-urls: http://localhost:2380 +listen-client-urls: http://localhost:2379 +initial-advertise-peer-urls: http://localhost:2380 +advertise-client-urls: http://localhost:2379 +initial-cluster: +initial-cluster-token: 'etcd-cluster' +initial-cluster-state: 'new' +""" + +DEFAULT_PROPERTIES = """ +snapshot-count: 10000 +heartbeat-interval: 100 +election-timeout: 1000 +quota-backend-bytes: 0 +max-snapshots: 5 +max-wals: 5 +strict-reconfig-check: false +enable-pprof: true +proxy: 'off' +proxy-failure-wait: 5000 +proxy-refresh-interval: 30000 +proxy-dial-timeout: 1000 +proxy-write-timeout: 5000 +proxy-read-timeout: 0 +force-new-cluster: false +auto-compaction-mode: periodic +auto-compaction-retention: "1" +""" + +TLS_PROPERTIES = """ +client-transport-security: + cert-file: + # Path to the client server TLS key file. + key-file: + client-cert-auth: false + trusted-ca-file: + auto-tls: false +peer-transport-security: + cert-file: + key-file: + client-cert-auth: false + trusted-ca-file: + auto-tls: false + allowed-cn: + allowed-hostname: +cipher-suites: [ + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 +] +tls-min-version: 'TLS1.2' +tls-max-version: 'TLS1.3' +""" + + +class ConfigManager: + """Handle the configuration of etcd.""" + + def __init__( + self, + state: ClusterState, + workload: WorkloadBase, + config: ConfigData, + ): + self.state = state + self.workload = workload + self.config = config + self.config_path = CONFIG_PATH + + @property + def config_properties(self) -> list[str]: + """Assemble the config properties. + + Returns: + List of properties to be written to the config file. + """ + properties = [ + f"log-level={self.config.get('log-level')}", + f"name={self.state.unit_server.unit_name}", + ] + DEFAULT_PROPERTIES.split("\n") + + return properties + + def set_config_properties(self) -> None: + """Write the config properties to the config file.""" + self.workload.write( + content="\n".join(self.config_properties), + path=self.config_path, + ) diff --git a/src/workload.py b/src/workload.py index e0742ea..527a7d5 100644 --- a/src/workload.py +++ b/src/workload.py @@ -5,6 +5,7 @@ """Implementation of WorkloadBase for running on VMs.""" import logging +import os from charms.operator_libs_linux.v2 import snap from typing_extensions import override @@ -48,3 +49,10 @@ def alive(self) -> bool: return bool(self.etcd.services[SNAP_SERVICE]["active"]) except KeyError: return False + + @override + def write(self, content: str, path: str) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + + with open(path, "w") as f: + f.write(content) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 5860959..8b9a482 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -21,14 +21,21 @@ def test_install_failure_blocked_status(): def test_start(): ctx = testing.Context(EtcdOperatorCharm) - relation = testing.PeerRelation(id=1, endpoint="etcd-cluster", peers_data={1: {}}) - state_in = testing.State(relations={relation}) + state_in = testing.State() - with patch("workload.EtcdWorkload.alive", return_value=True): + with ( + patch("workload.EtcdWorkload.alive", return_value=True), + patch("workload.EtcdWorkload.write"), + patch("workload.EtcdWorkload.start"), + ): state_out = ctx.run(ctx.on.start(), state_in) assert state_out.unit_status == ops.ActiveStatus() - with patch("workload.EtcdWorkload.alive", return_value=False): + with ( + patch("workload.EtcdWorkload.alive", return_value=False), + patch("workload.EtcdWorkload.write"), + patch("workload.EtcdWorkload.start"), + ): state_out = ctx.run(ctx.on.start(), state_in) assert state_out.unit_status == ops.BlockedStatus("etcd service not running") @@ -40,3 +47,14 @@ def test_update_status(): with patch("workload.EtcdWorkload.alive", return_value=False): state_out = ctx.run(ctx.on.update_status(), state_in) assert state_out.unit_status == ops.BlockedStatus("etcd service not running") + + +def test_peer_relation_changed(): + test_data = {"hostname": "my_hostname", "ip": "my_ip"} + + ctx = testing.Context(EtcdOperatorCharm) + relation = testing.PeerRelation(id=1, endpoint="etcd-cluster") + state_in = testing.State(relations={relation}) + with patch("managers.cluster.ClusterManager.get_host_mapping", return_value=test_data): + state_out = ctx.run(ctx.on.relation_created(relation=relation), state_in) + assert state_out.get_relation(1).local_unit_data.get("hostname") == test_data["hostname"] From 77d2395c3c334e691f30b9e7bd01c287d270d08c Mon Sep 17 00:00:00 2001 From: reneradoi Date: Fri, 15 Nov 2024 15:51:52 +0000 Subject: [PATCH 13/50] add basic configuration required for initial startup of etcd cluster --- src/core/cluster.py | 62 ++++++++++++++++++++++++++++++++++++---- src/core/models.py | 36 +++++++++++++++++++---- src/events/etcd.py | 13 ++++++++- src/literals.py | 2 +- src/managers/config.py | 37 +++++++++++++++--------- tests/unit/test_charm.py | 14 +++++++++ 6 files changed, 136 insertions(+), 28 deletions(-) diff --git a/src/core/cluster.py b/src/core/cluster.py index 236518e..240ff4e 100644 --- a/src/core/cluster.py +++ b/src/core/cluster.py @@ -5,12 +5,16 @@ """Objects representing the state of EtcdOperatorCharm.""" import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, Set -from charms.data_platform_libs.v0.data_interfaces import DataPeerUnitData -from ops import Object, Relation +from charms.data_platform_libs.v0.data_interfaces import ( + DataPeerData, + DataPeerOtherUnitData, + DataPeerUnitData, +) +from ops import Object, Relation, Unit -from core.models import EtcdServer +from core.models import EtcdCluster, EtcdServer from literals import PEER_RELATION, SUBSTRATES if TYPE_CHECKING: @@ -25,19 +29,65 @@ 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_unit_interface = DataPeerUnitData(self.model, relation_name=PEER_RELATION) @property def peer_relation(self) -> Relation | None: - """The cluster peer relation.""" + """Get the cluster peer relation.""" return self.model.get_relation(PEER_RELATION) @property def unit_server(self) -> EtcdServer: - """The server state of the current running unit.""" + """Get the server state of this unit.""" return EtcdServer( relation=self.peer_relation, data_interface=self.peer_unit_interface, component=self.model.unit, substrate=self.substrate, ) + + @property + def peer_units_data_interfaces(self) -> Dict[Unit, DataPeerOtherUnitData]: + """Get unit data interface of all peer units from the cluster peer relation.""" + if not self.peer_relation or not self.peer_relation.units: + return {} + + return { + unit: DataPeerOtherUnitData(model=self.model, unit=unit, relation_name=PEER_RELATION) + for unit in self.peer_relation.units + } + + @property + def cluster(self) -> EtcdCluster: + """Get the cluster state of the entire etcd application.""" + return EtcdCluster( + relation=self.peer_relation, + data_interface=self.peer_app_interface, + component=self.model.app, + substrate=self.substrate, + ) + + @property + def servers(self) -> Set[EtcdServer]: + """Get all servers/units in the current peer relation, including this unit itself. + + Returns: + Set of EtcdServers with their unit data. + """ + if not self.peer_relation: + return set() + + servers = set() + for unit, data_interface in self.peer_units_data_interfaces.items(): + servers.add( + EtcdServer( + relation=self.peer_relation, + data_interface=data_interface, + component=unit, + substrate=self.substrate, + ) + ) + servers.add(self.unit_server) + + return servers diff --git a/src/core/models.py b/src/core/models.py index 366fb56..006c14d 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -7,10 +7,10 @@ import logging from collections.abc import MutableMapping -from charms.data_platform_libs.v0.data_interfaces import Data, DataPeerUnitData +from charms.data_platform_libs.v0.data_interfaces import Data, DataPeerData, DataPeerUnitData from ops.model import Application, Relation, Unit -from literals import PEER_PORT, SERVER_PORT, SUBSTRATES +from literals import CLIENT_PORT, PEER_PORT, SUBSTRATES logger = logging.getLogger(__name__) @@ -76,6 +76,11 @@ def unit_name(self) -> str: """The id of the unit from the unit name.""" return self.unit.name + @property + def member_name(self) -> str: + """The Human-readable name for this etcd cluster member.""" + return f"etcd{self.unit_id}" + @property def hostname(self) -> str: """The hostname for the unit.""" @@ -88,10 +93,29 @@ def ip(self) -> str: @property def peer_url(self) -> str: - """The peer connection endpoint for the Etcd server.""" - return f"http://{self.ip}:{SERVER_PORT}" + """The peer connection endpoint for the etcd server.""" + return f"http://{self.ip}:{PEER_PORT}" @property def client_url(self) -> str: - """The client connection endpoint for the Etcd server.""" - return f"http://{self.ip}:{PEER_PORT}" + """The client connection endpoint for the etcd server.""" + return f"http://{self.ip}:{CLIENT_PORT}" + + +class EtcdCluster(RelationState): + """State/Relation data collection for the etcd application.""" + + def __init__( + self, + relation: Relation | None, + data_interface: DataPeerData, + component: Application, + substrate: SUBSTRATES, + ): + super().__init__(relation, data_interface, component, substrate) + self.app = component + + @property + 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", "") diff --git a/src/events/etcd.py b/src/events/etcd.py index 2581712..4995c6d 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -2,7 +2,7 @@ # Copyright 2024 Canonical Limited # See LICENSE file for licensing details. -"""Etcd related event handlers.""" +"""Etcd related and core event handlers.""" import logging from typing import TYPE_CHECKING @@ -70,6 +70,16 @@ def _on_install(self, event: ops.InstallEvent) -> None: def _on_start(self, event: ops.StartEvent) -> None: """Handle start event.""" + # Make sure all planned units have joined the peer relation before starting the cluster + if ( + not self.charm.state.peer_relation + or len(self.charm.state.peer_relation.units) + 1 < self.charm.app.planned_units() + ): + logger.info("Deferring start because not all units joined peer-relation.") + self.charm.set_status(Status.NO_PEER_RELATION) + event.defer() + return + self.config_manager.set_config_properties() self.charm.workload.start() @@ -86,6 +96,7 @@ def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: def _on_cluster_relation_created(self, event: RelationCreatedEvent) -> None: """Handle event received by a new unit when joining the cluster relation.""" self.charm.state.unit_server.update(self.cluster_manager.get_host_mapping()) + self.charm.state.cluster.update({"initial-cluster-state": "new"}) def _on_cluster_relation_changed(self, event: RelationChangedEvent) -> None: """Handle all events related to the cluster-peer relation.""" diff --git a/src/literals.py b/src/literals.py index 35af22d..bb6762a 100644 --- a/src/literals.py +++ b/src/literals.py @@ -18,7 +18,7 @@ CONFIG_PATH = "/var/snap/etcd/common/etcd.conf.yml" PEER_RELATION = "etcd-cluster" -SERVER_PORT = 2379 +CLIENT_PORT = 2379 PEER_PORT = 2380 DebugLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"] diff --git a/src/managers/config.py b/src/managers/config.py index 0cd6dad..9ce1d42 100644 --- a/src/managers/config.py +++ b/src/managers/config.py @@ -14,19 +14,8 @@ logger = logging.getLogger(__name__) -DYNAMIC_PROPERTIES = """ -data-dir: -> get from snap -wal-dir: -> get from snap -listen-peer-urls: http://localhost:2380 -listen-client-urls: http://localhost:2379 -initial-advertise-peer-urls: http://localhost:2380 -advertise-client-urls: http://localhost:2379 -initial-cluster: -initial-cluster-token: 'etcd-cluster' -initial-cluster-state: 'new' -""" - DEFAULT_PROPERTIES = """ +initial-cluster-token: 'etcd-cluster' snapshot-count: 10000 heartbeat-interval: 100 election-timeout: 1000 @@ -46,6 +35,8 @@ auto-compaction-retention: "1" """ +# these config properties are not used at the moment +# they are only listed here for completeness TLS_PROPERTIES = """ client-transport-security: cert-file: @@ -93,8 +84,13 @@ def config_properties(self) -> list[str]: List of properties to be written to the config file. """ properties = [ - f"log-level={self.config.get('log-level')}", - f"name={self.state.unit_server.unit_name}", + f"name: {self.state.unit_server.member_name}", + f"initial-advertise-peer-urls: {self.state.unit_server.peer_url}", + f"initial-cluster-state: {self.state.cluster.initial_cluster_state}", + f"listen-peer-urls: {self.state.unit_server.peer_url}", + f"listen-client-urls: {self.state.unit_server.client_url}", + f"advertise-client-urls: {self.state.unit_server.client_url}", + f"initial-cluster: {self._get_cluster_endpoints()}", ] + DEFAULT_PROPERTIES.split("\n") return properties @@ -105,3 +101,16 @@ def set_config_properties(self) -> None: content="\n".join(self.config_properties), path=self.config_path, ) + + def _get_cluster_endpoints(self) -> str: + """Concatenate peer-urls of all cluster members. + + Returns: + Str of member name and peer url for all cluster members in required syntax, e.g.: + etcd1=http://10.54.237.109:2380,etcd2=http://10.54.237.57:2380 + """ + cluster_endpoints = ",".join( + f"{server.member_name}={server.peer_url}" for server in self.state.servers + ) + + return cluster_endpoints diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 8b9a482..c7b5b5d 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -23,6 +23,19 @@ def test_start(): ctx = testing.Context(EtcdOperatorCharm) state_in = testing.State() + # without peer relation the charm should not start + with ( + patch("workload.EtcdWorkload.alive", return_value=True), + patch("workload.EtcdWorkload.write"), + patch("workload.EtcdWorkload.start"), + ): + state_out = ctx.run(ctx.on.start(), state_in) + assert state_out.unit_status == ops.MaintenanceStatus("no peer relation available") + + # with peer relation, it should go to active status + relation = testing.PeerRelation(id=1, endpoint="etcd-cluster") + state_in = testing.State(relations={relation}) + with ( patch("workload.EtcdWorkload.alive", return_value=True), patch("workload.EtcdWorkload.write"), @@ -31,6 +44,7 @@ def test_start(): state_out = ctx.run(ctx.on.start(), state_in) assert state_out.unit_status == ops.ActiveStatus() + # 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"), From ccae158036bfe62eadd4afc1892f7c70fd22796a Mon Sep 17 00:00:00 2001 From: reneradoi Date: Mon, 18 Nov 2024 16:07:38 +0000 Subject: [PATCH 14/50] use `pathlib` instead of `os` to create filepath for config file --- src/core/workload.py | 2 +- src/literals.py | 2 +- src/managers/config.py | 8 ++++---- src/workload.py | 9 +++++---- tests/unit/test_charm.py | 6 +++--- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/core/workload.py b/src/core/workload.py index 06f8130..8ae58c8 100644 --- a/src/core/workload.py +++ b/src/core/workload.py @@ -21,6 +21,6 @@ def alive(self) -> bool: pass @abstractmethod - def write(self, content: str, path: str) -> None: + def write_file(self, content: str, file: str) -> None: """Write content to a file.""" pass diff --git a/src/literals.py b/src/literals.py index bb6762a..95875a8 100644 --- a/src/literals.py +++ b/src/literals.py @@ -15,7 +15,7 @@ SNAP_SERVICE = "etcd" # this path will be updated when we switch to charmed-etcd snap # it's the current config path for the legacy-etcd snap -CONFIG_PATH = "/var/snap/etcd/common/etcd.conf.yml" +CONFIG_FILE = "/var/snap/etcd/common/etcd.conf.yml" PEER_RELATION = "etcd-cluster" CLIENT_PORT = 2379 diff --git a/src/managers/config.py b/src/managers/config.py index 9ce1d42..99aeafb 100644 --- a/src/managers/config.py +++ b/src/managers/config.py @@ -10,7 +10,7 @@ from core.cluster import ClusterState from core.workload import WorkloadBase -from literals import CONFIG_PATH +from literals import CONFIG_FILE logger = logging.getLogger(__name__) @@ -74,7 +74,7 @@ def __init__( self.state = state self.workload = workload self.config = config - self.config_path = CONFIG_PATH + self.config_file = CONFIG_FILE @property def config_properties(self) -> list[str]: @@ -97,9 +97,9 @@ def config_properties(self) -> list[str]: def set_config_properties(self) -> None: """Write the config properties to the config file.""" - self.workload.write( + self.workload.write_file( content="\n".join(self.config_properties), - path=self.config_path, + file=self.config_file, ) def _get_cluster_endpoints(self) -> str: diff --git a/src/workload.py b/src/workload.py index 527a7d5..aef60f3 100644 --- a/src/workload.py +++ b/src/workload.py @@ -5,7 +5,7 @@ """Implementation of WorkloadBase for running on VMs.""" import logging -import os +import pathlib from charms.operator_libs_linux.v2 import snap from typing_extensions import override @@ -51,8 +51,9 @@ def alive(self) -> bool: return False @override - def write(self, content: str, path: str) -> None: - os.makedirs(os.path.dirname(path), exist_ok=True) + def write_file(self, content: str, file: str) -> None: + path = "/".join(file.split("/")[:-1]) + pathlib.Path(path).mkdir(parents=True, exist_ok=True) - with open(path, "w") as f: + with open(file, "w") as f: f.write(content) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index c7b5b5d..b0e0afe 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -26,7 +26,7 @@ def test_start(): # without peer relation the charm should not start with ( patch("workload.EtcdWorkload.alive", return_value=True), - patch("workload.EtcdWorkload.write"), + patch("workload.EtcdWorkload.write_file"), patch("workload.EtcdWorkload.start"), ): state_out = ctx.run(ctx.on.start(), state_in) @@ -38,7 +38,7 @@ def test_start(): with ( patch("workload.EtcdWorkload.alive", return_value=True), - patch("workload.EtcdWorkload.write"), + patch("workload.EtcdWorkload.write_file"), patch("workload.EtcdWorkload.start"), ): state_out = ctx.run(ctx.on.start(), state_in) @@ -47,7 +47,7 @@ def test_start(): # 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"), + patch("workload.EtcdWorkload.write_file"), patch("workload.EtcdWorkload.start"), ): state_out = ctx.run(ctx.on.start(), state_in) From 84c378f49c82ae4f5ce476ec2b4049195808914f Mon Sep 17 00:00:00 2001 From: reneradoi Date: Tue, 19 Nov 2024 10:28:42 +0000 Subject: [PATCH 15/50] use `Path` object to create directory path and write file --- src/workload.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/workload.py b/src/workload.py index aef60f3..770cb78 100644 --- a/src/workload.py +++ b/src/workload.py @@ -5,7 +5,7 @@ """Implementation of WorkloadBase for running on VMs.""" import logging -import pathlib +from pathlib import Path from charms.operator_libs_linux.v2 import snap from typing_extensions import override @@ -52,8 +52,6 @@ def alive(self) -> bool: @override def write_file(self, content: str, file: str) -> None: - path = "/".join(file.split("/")[:-1]) - pathlib.Path(path).mkdir(parents=True, exist_ok=True) - - with open(file, "w") as f: - f.write(content) + path = Path(file) + path.parent.mkdir(exist_ok=True, parents=True) + path.write_text(content) From 0142dc42a2555e2f307f51c8419843931735598d Mon Sep 17 00:00:00 2001 From: reneradoi Date: Wed, 20 Nov 2024 09:36:30 +0000 Subject: [PATCH 16/50] rename peer-relation to `etcd-peers` for consistency within data platform --- metadata.yaml | 4 ++-- src/literals.py | 2 +- tests/unit/test_charm.py | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/metadata.yaml b/metadata.yaml index 02c533d..1867830 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -13,5 +13,5 @@ description: | etcd on virtual machines. peers: - etcd-cluster: - interface: etcd_cluster + etcd-peers: + interface: etcd_peers diff --git a/src/literals.py b/src/literals.py index 95875a8..eb18142 100644 --- a/src/literals.py +++ b/src/literals.py @@ -17,7 +17,7 @@ # it's the current config path for the legacy-etcd snap CONFIG_FILE = "/var/snap/etcd/common/etcd.conf.yml" -PEER_RELATION = "etcd-cluster" +PEER_RELATION = "etcd-peers" CLIENT_PORT = 2379 PEER_PORT = 2380 diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index b0e0afe..ae643de 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -8,6 +8,7 @@ from ops import testing from charm import EtcdOperatorCharm +from literals import PEER_RELATION def test_install_failure_blocked_status(): @@ -33,7 +34,7 @@ def test_start(): assert state_out.unit_status == ops.MaintenanceStatus("no peer relation available") # with peer relation, it should go to active status - relation = testing.PeerRelation(id=1, endpoint="etcd-cluster") + relation = testing.PeerRelation(id=1, endpoint=PEER_RELATION) state_in = testing.State(relations={relation}) with ( @@ -67,7 +68,7 @@ def test_peer_relation_changed(): test_data = {"hostname": "my_hostname", "ip": "my_ip"} ctx = testing.Context(EtcdOperatorCharm) - relation = testing.PeerRelation(id=1, endpoint="etcd-cluster") + relation = testing.PeerRelation(id=1, endpoint=PEER_RELATION) state_in = testing.State(relations={relation}) with patch("managers.cluster.ClusterManager.get_host_mapping", return_value=test_data): state_out = ctx.run(ctx.on.relation_created(relation=relation), state_in) From eb6bf16b6cb6883687def7cccd010b717cbbdc79 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Wed, 20 Nov 2024 09:44:13 +0000 Subject: [PATCH 17/50] use `pop` instead of `del` for deleting keys from relation data in order to avoid KeyErrors --- src/core/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/models.py b/src/core/models.py index 006c14d..332a9c6 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -50,7 +50,7 @@ def update(self, items: dict[str, str]) -> None: self.relation_data.update(update_content) for field in delete_fields: - del self.relation_data[field] + self.relation_data.pop(field, None) class EtcdServer(RelationState): From d023cabe66bc299badecca4e3e939e7d86da80f0 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Wed, 20 Nov 2024 10:03:32 +0000 Subject: [PATCH 18/50] use `app.name` instead of hardcoded `etcd` for cluster-member-name --- src/core/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/models.py b/src/core/models.py index 332a9c6..9cbe051 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -79,7 +79,7 @@ def unit_name(self) -> str: @property def member_name(self) -> str: """The Human-readable name for this etcd cluster member.""" - return f"etcd{self.unit_id}" + return f"{self.unit.app.name}{self.unit_id}" @property def hostname(self) -> str: From d89d7d0d6b2c0ce5bf3d43c91b2c4c2e051cab92 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Wed, 20 Nov 2024 10:25:19 +0000 Subject: [PATCH 19/50] use `socket.gethostbyname` for ip address --- src/managers/cluster.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/managers/cluster.py b/src/managers/cluster.py index 5152a63..87af327 100644 --- a/src/managers/cluster.py +++ b/src/managers/cluster.py @@ -23,11 +23,6 @@ def get_host_mapping(self) -> dict[str, str]: Dict of string keys 'hostname', 'ip' and their values """ hostname = socket.gethostname() - - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.settimeout(0) - s.connect(("10.10.10.10", 1)) - ip = s.getsockname()[0] - s.close() + ip = socket.gethostbyname(hostname) return {"hostname": hostname, "ip": ip} From e39a32277afa134d14299c6298e0f340c16f147a Mon Sep 17 00:00:00 2001 From: reneradoi Date: Wed, 20 Nov 2024 10:52:38 +0000 Subject: [PATCH 20/50] add retry-mechanism to workload-init and installation --- poetry.lock | 17 ++++++++++++++++- pyproject.toml | 2 ++ src/events/etcd.py | 4 +--- src/workload.py | 6 +++++- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index b11b032..0cfd1f1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1732,6 +1732,21 @@ pure-eval = "*" [package.extras] tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] +[[package]] +name = "tenacity" +version = "9.0.0" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539"}, + {file = "tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + [[package]] name = "tomli" version = "2.1.0" @@ -1937,4 +1952,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "30188db9a37f289d25b9e4ad80bfed302b0eaf25ca17bd51c3d3233a4173fc6e" +content-hash = "c28cded626ff2b856001059c9839daac3120613087e02ff723d69c9c084cea10" diff --git a/pyproject.toml b/pyproject.toml index 72cd047..ece49a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ package-mode = false [tool.poetry.dependencies] python = "^3.10" ops = "^2.17.0" +tenacity = "*" [tool.poetry.group.charm-libs.dependencies] # data_platform_libs/v0/data_interfaces.py @@ -57,6 +58,7 @@ allure-pytest-collection-report = {git = "https://github.com/canonical/data-plat # pin websockets to <14.0 because of breaking changes in this version # see also: https://github.com/juju/python-libjuju/issues/1184 websockets = "<14.0" +tenacity = "*" [tool.coverage.run] branch = true diff --git a/src/events/etcd.py b/src/events/etcd.py index 4995c6d..b7b1476 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -62,10 +62,8 @@ def __init__(self, charm: "EtcdOperatorCharm"): def _on_install(self, event: ops.InstallEvent) -> None: """Handle install event.""" - install = self.charm.workload.install() - if not install: + if not self.charm.workload.install(): self.charm.set_status(Status.SERVICE_NOT_INSTALLED) - event.defer() return def _on_start(self, event: ops.StartEvent) -> None: diff --git a/src/workload.py b/src/workload.py index 770cb78..d19e535 100644 --- a/src/workload.py +++ b/src/workload.py @@ -8,6 +8,7 @@ from pathlib import Path from charms.operator_libs_linux.v2 import snap +from tenacity import Retrying, retry, stop_after_attempt, wait_fixed from typing_extensions import override from core.workload import WorkloadBase @@ -20,7 +21,9 @@ class EtcdWorkload(WorkloadBase): """Implementation of WorkloadBase for running on VMs.""" def __init__(self): - self.etcd = snap.SnapCache()[SNAP_NAME] + for attempt in Retrying(stop=stop_after_attempt(5), wait=wait_fixed(5)): + with attempt: + self.etcd = snap.SnapCache()[SNAP_NAME] @override def start(self) -> None: @@ -29,6 +32,7 @@ def start(self) -> None: except snap.SnapError as e: logger.exception(str(e)) + @retry(stop=stop_after_attempt(3), wait=wait_fixed(5), reraise=True) def install(self) -> bool: """Install the etcd snap from the snap store. From 81fe2d98915112bee6dbb2c95bca058b8958b19b Mon Sep 17 00:00:00 2001 From: reneradoi Date: Fri, 22 Nov 2024 17:30:28 +0000 Subject: [PATCH 21/50] WIP: add `EtcdClient` class for executing `etcdctl` commands, add `get_leader` method to ClusterManager --- src/charm.py | 2 +- src/events/etcd.py | 6 ++-- src/managers/cluster.py | 61 ++++++++++++++++++++++++++++++++++++++-- tests/unit/test_charm.py | 8 +++++- 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/charm.py b/src/charm.py index 4d0bbc1..0d0c14d 100755 --- a/src/charm.py +++ b/src/charm.py @@ -26,7 +26,7 @@ def __init__(self, *args): self.state = ClusterState(self, substrate=SUBSTRATE) # --- EVENT HANDLERS --- - self.etcd_events = EtcdEvents(self) + self.etcd_events = EtcdEvents(self, self.state) def set_status(self, key: Status) -> None: """Set charm status.""" diff --git a/src/events/etcd.py b/src/events/etcd.py index b7b1476..7c78a01 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -17,6 +17,7 @@ RelationJoinedEvent, ) +from core.cluster import ClusterState from literals import PEER_RELATION, Status from managers.cluster import ClusterManager from managers.config import ConfigManager @@ -30,12 +31,13 @@ class EtcdEvents(Object): """Handle all base and etcd related events.""" - def __init__(self, charm: "EtcdOperatorCharm"): + def __init__(self, charm: "EtcdOperatorCharm", state: ClusterState): super().__init__(charm, key="etcd_events") self.charm = charm + self.state = state # --- MANAGERS --- - self.cluster_manager = ClusterManager() + self.cluster_manager = ClusterManager(self.state) self.config_manager = ConfigManager( state=self.charm.state, workload=self.charm.workload, config=self.charm.config ) diff --git a/src/managers/cluster.py b/src/managers/cluster.py index 87af327..17c1f59 100644 --- a/src/managers/cluster.py +++ b/src/managers/cluster.py @@ -4,17 +4,29 @@ """Manager for all cluster/quorum/rbac related tasks.""" +import json import logging import socket +import subprocess + +from core.cluster import ClusterState 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): - pass + def __init__(self, state: ClusterState): + self.state = state + self.cluster_endpoints = [server.client_url for server in self.state.servers] + self.leader = self.get_leader() def get_host_mapping(self) -> dict[str, str]: """Collect hostname mapping for current unit. @@ -26,3 +38,48 @@ def get_host_mapping(self) -> dict[str, str]: ip = socket.gethostbyname(hostname) return {"hostname": hostname, "ip": ip} + + def get_leader(self) -> str: + """Query the etcd cluster for the raft leader and return the client_url as string.""" + leader = "" + + # 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) + try: + endpoint_status = client.get_endpoint_status() + member_id = endpoint_status["Status"]["header"]["member_id"] + leader_id = endpoint_status["Status"]["leader"] + if member_id == leader_id: + leader = endpoint + logger.info(f"Raft leader found: {leader}") + break + except KeyError as e: + logger.error(f"No raft leader found in cluster: {e}") + + return leader + + +class EtcdClient: + """Handle etcd client connections and run etcdctl commands.""" + + def __init__( + self, + client_url: str, + ): + self.client_url = client_url + + def get_endpoint_status(self) -> dict: + """Run the `etcdctl endpoint status` command and return the result as dict.""" + try: + result = subprocess.check_output( + ["etcdctl", "endpoint", "status", f"--endpoints={self.client_url}", "-w=json"] + ).decode("UTF-8") + except subprocess.CalledProcessError as e: + logger.error(f"Error: {e}") + return {} + + endpoint_status = json.loads(result)[0] + + return endpoint_status diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index ae643de..c8a7288 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -29,6 +29,7 @@ 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") @@ -41,6 +42,7 @@ 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() @@ -50,6 +52,7 @@ def test_start(): patch("workload.EtcdWorkload.alive", return_value=False), 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.BlockedStatus("etcd service not running") @@ -70,6 +73,9 @@ def test_peer_relation_changed(): ctx = testing.Context(EtcdOperatorCharm) relation = testing.PeerRelation(id=1, endpoint=PEER_RELATION) state_in = testing.State(relations={relation}) - with patch("managers.cluster.ClusterManager.get_host_mapping", return_value=test_data): + with ( + patch("managers.cluster.ClusterManager.get_host_mapping", return_value=test_data), + patch("managers.cluster.ClusterManager.get_leader"), + ): state_out = ctx.run(ctx.on.relation_created(relation=relation), state_in) assert state_out.get_relation(1).local_unit_data.get("hostname") == test_data["hostname"] From 38800a9b4d0c6f91b90ee5d7b98fd289d36a0187 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Mon, 25 Nov 2024 08:30:41 +0000 Subject: [PATCH 22/50] WIP: refactor - managers should be instantiated from the charm, not from the event handlers --- src/charm.py | 10 +++++++++- src/events/etcd.py | 19 +++++-------------- src/managers/cluster.py | 1 - 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/charm.py b/src/charm.py index 0d0c14d..3c463b2 100755 --- a/src/charm.py +++ b/src/charm.py @@ -12,6 +12,8 @@ from core.cluster import ClusterState from events.etcd import EtcdEvents from literals import SUBSTRATE, DebugLevel, Status +from managers.cluster import ClusterManager +from managers.config import ConfigManager from workload import EtcdWorkload logger = logging.getLogger(__name__) @@ -25,8 +27,14 @@ def __init__(self, *args): self.workload = EtcdWorkload() self.state = ClusterState(self, substrate=SUBSTRATE) + # --- MANAGERS --- + self.cluster_manager = ClusterManager(self.state) + self.config_manager = ConfigManager( + state=self.state, workload=self.workload, config=self.config + ) + # --- EVENT HANDLERS --- - self.etcd_events = EtcdEvents(self, self.state) + self.etcd_events = EtcdEvents(self) def set_status(self, key: Status) -> None: """Set charm status.""" diff --git a/src/events/etcd.py b/src/events/etcd.py index 7c78a01..3b7636c 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -17,10 +17,7 @@ RelationJoinedEvent, ) -from core.cluster import ClusterState from literals import PEER_RELATION, Status -from managers.cluster import ClusterManager -from managers.config import ConfigManager if TYPE_CHECKING: from charm import EtcdOperatorCharm @@ -31,16 +28,9 @@ class EtcdEvents(Object): """Handle all base and etcd related events.""" - def __init__(self, charm: "EtcdOperatorCharm", state: ClusterState): + def __init__(self, charm: "EtcdOperatorCharm"): super().__init__(charm, key="etcd_events") self.charm = charm - self.state = state - - # --- MANAGERS --- - self.cluster_manager = ClusterManager(self.state) - self.config_manager = ConfigManager( - state=self.charm.state, workload=self.charm.workload, config=self.charm.config - ) # --- Core etcd charm events --- @@ -80,7 +70,7 @@ def _on_start(self, event: ops.StartEvent) -> None: event.defer() return - self.config_manager.set_config_properties() + self.charm.config_manager.set_config_properties() self.charm.workload.start() @@ -95,8 +85,9 @@ def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: def _on_cluster_relation_created(self, event: RelationCreatedEvent) -> None: """Handle event received by a new unit when joining the cluster relation.""" - self.charm.state.unit_server.update(self.cluster_manager.get_host_mapping()) - self.charm.state.cluster.update({"initial-cluster-state": "new"}) + self.charm.state.unit_server.update(self.charm.cluster_manager.get_host_mapping()) + if self.charm.unit.is_leader(): + self.charm.state.cluster.update({"initial-cluster-state": "new"}) def _on_cluster_relation_changed(self, event: RelationChangedEvent) -> None: """Handle all events related to the cluster-peer relation.""" diff --git a/src/managers/cluster.py b/src/managers/cluster.py index 17c1f59..d8dcc17 100644 --- a/src/managers/cluster.py +++ b/src/managers/cluster.py @@ -26,7 +26,6 @@ class ClusterManager: def __init__(self, state: ClusterState): self.state = state self.cluster_endpoints = [server.client_url for server in self.state.servers] - self.leader = self.get_leader() def get_host_mapping(self) -> dict[str, str]: """Collect hostname mapping for current unit. From 233d0de5462193db0309a0bc32d85a5014ab91e8 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Mon, 25 Nov 2024 11:25:51 +0000 Subject: [PATCH 23/50] WIP: add generic method to execute `etcdctl` commands --- src/events/etcd.py | 6 ++++- src/managers/cluster.py | 59 +++++++++++++++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/events/etcd.py b/src/events/etcd.py index 3b7636c..31bbd23 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -99,7 +99,11 @@ def _on_cluster_relation_departed(self, event: RelationDepartedEvent) -> None: def _on_cluster_relation_joined(self, event: RelationJoinedEvent) -> None: """Handle event received by all units when a new unit joins the cluster relation.""" - pass + # 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(): + logger.info(f"Raft leader: {raft_leader}") def _on_leader_elected(self, event: LeaderElectedEvent) -> None: """Handle all events in the 'cluster' peer relation.""" diff --git a/src/managers/cluster.py b/src/managers/cluster.py index d8dcc17..8f7afea 100644 --- a/src/managers/cluster.py +++ b/src/managers/cluster.py @@ -54,8 +54,8 @@ def get_leader(self) -> str: leader = endpoint logger.info(f"Raft leader found: {leader}") break - except KeyError as e: - logger.error(f"No raft leader found in cluster: {e}") + except KeyError: + logger.warning("No raft leader found in cluster.") return leader @@ -70,15 +70,52 @@ def __init__( self.client_url = client_url def get_endpoint_status(self) -> dict: - """Run the `etcdctl endpoint status` command and return the result as dict.""" + """Run the `endpoint status` command and return the result as dict.""" + endpoint_status = {} + if result := self._run_etcdctl( + command="endpoint", + subcommand="status", + endpoints=self.client_url, + output_format="json", + ): + endpoint_status = json.loads(result)[0] + + return endpoint_status + + def _run_etcdctl( + self, + command: str, + subcommand: str | None, + endpoints: str, + output_format: str | None, + ) -> str: + """Execute `etcdctl` command via subprocess. + + 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 + output_format: set the output format (fields, json, protobuf, simple, table) + ... + + Returns: + The output of the subprocess-command as a string. + """ try: - result = subprocess.check_output( - ["etcdctl", "endpoint", "status", f"--endpoints={self.client_url}", "-w=json"] - ).decode("UTF-8") + result = subprocess.run( + args=[ + "etcdctl", + command, + subcommand, + f"--endpoints={endpoints}", + f"-w={output_format}", + ], + check=True, + capture_output=True, + text=True, + ).stdout except subprocess.CalledProcessError as e: - logger.error(f"Error: {e}") - return {} + logger.warning(e) + return "" - endpoint_status = json.loads(result)[0] - - return endpoint_status + return result From 834efd9029c2f625482dbc90b9ad283bdded029e Mon Sep 17 00:00:00 2001 From: reneradoi Date: Mon, 25 Nov 2024 12:18:27 +0000 Subject: [PATCH 24/50] add unit test coverage for EtcdClient and ClusterManager --- tests/unit/test_charm.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index c8a7288..9751090 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -8,7 +8,7 @@ from ops import testing from charm import EtcdOperatorCharm -from literals import PEER_RELATION +from literals import CLIENT_PORT, PEER_RELATION def test_install_failure_blocked_status(): @@ -67,7 +67,7 @@ def test_update_status(): assert state_out.unit_status == ops.BlockedStatus("etcd service not running") -def test_peer_relation_changed(): +def test_peer_relation_created(): test_data = {"hostname": "my_hostname", "ip": "my_ip"} ctx = testing.Context(EtcdOperatorCharm) @@ -79,3 +79,25 @@ def test_peer_relation_changed(): ): state_out = ctx.run(ctx.on.relation_created(relation=relation), state_in) assert state_out.get_relation(1).local_unit_data.get("hostname") == test_data["hostname"] + + +def test_get_leader(): + test_ip = "10.54.237.119" + test_data = { + "Endpoint": f"http://{test_ip}:{CLIENT_PORT}", + "Status": { + "header": { + "cluster_id": 9102535641521235766, + "member_id": 11187096354790748301, + }, + "version": "3.4.22", + "leader": 11187096354790748301, + }, + } + + ctx = testing.Context(EtcdOperatorCharm) + relation = testing.PeerRelation(id=1, endpoint=PEER_RELATION, local_unit_data={"ip": test_ip}) + state_in = testing.State(relations={relation}) + with patch("managers.cluster.EtcdClient.get_endpoint_status", return_value=test_data): + with ctx(ctx.on.relation_joined(relation=relation), state_in) as manager: + assert manager.charm.cluster_manager.get_leader() == f"http://{test_ip}:{CLIENT_PORT}" From 6e3879b0ea7f0dc93725afd2546a256733b75f37 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Mon, 25 Nov 2024 14:07:28 +0000 Subject: [PATCH 25/50] doc string comment --- src/managers/cluster.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/managers/cluster.py b/src/managers/cluster.py index 8f7afea..227b327 100644 --- a/src/managers/cluster.py +++ b/src/managers/cluster.py @@ -91,6 +91,10 @@ def _run_etcdctl( ) -> str: """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. + 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` @@ -99,7 +103,10 @@ def _run_etcdctl( ... Returns: - The output of the subprocess-command as a string. + The output of the subprocess-command as a string. In case of error, this will + return an empty string. It will not raise an error in order to leave error handling + up to the caller. Depending on what command is executed, the ways of handling errors + might differ. """ try: result = subprocess.run( From e45b742f9c88dac83f40944e4903fc44acdcc7b6 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Mon, 25 Nov 2024 16:14:12 +0000 Subject: [PATCH 26/50] minor changes on comments, logging and naming --- src/managers/cluster.py | 3 ++- tests/unit/test_charm.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/managers/cluster.py b/src/managers/cluster.py index 227b327..b652e8e 100644 --- a/src/managers/cluster.py +++ b/src/managers/cluster.py @@ -52,9 +52,10 @@ def get_leader(self) -> str: leader_id = endpoint_status["Status"]["leader"] if member_id == leader_id: leader = endpoint - logger.info(f"Raft leader found: {leader}") break 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.") return leader diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 9751090..81c1cdc 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -99,5 +99,5 @@ def test_get_leader(): relation = testing.PeerRelation(id=1, endpoint=PEER_RELATION, local_unit_data={"ip": test_ip}) state_in = testing.State(relations={relation}) with patch("managers.cluster.EtcdClient.get_endpoint_status", return_value=test_data): - with ctx(ctx.on.relation_joined(relation=relation), state_in) as manager: - assert manager.charm.cluster_manager.get_leader() == f"http://{test_ip}:{CLIENT_PORT}" + 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}" From fc7f80a6631f7404bfb11d0f6642e71342a30f2d Mon Sep 17 00:00:00 2001 From: reneradoi Date: Tue, 26 Nov 2024 08:04:10 +0000 Subject: [PATCH 27/50] return `None` instead of empty strings to enforce type checking --- src/managers/cluster.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/managers/cluster.py b/src/managers/cluster.py index b652e8e..d63bf7e 100644 --- a/src/managers/cluster.py +++ b/src/managers/cluster.py @@ -38,10 +38,8 @@ def get_host_mapping(self) -> dict[str, str]: return {"hostname": hostname, "ip": ip} - def get_leader(self) -> str: + def get_leader(self) -> str | None: """Query the etcd cluster for the raft leader and return the client_url as string.""" - leader = "" - # 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: @@ -52,13 +50,13 @@ def get_leader(self) -> str: leader_id = endpoint_status["Status"]["leader"] if member_id == leader_id: leader = endpoint - break + return leader 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.") - return leader + return None class EtcdClient: @@ -88,8 +86,8 @@ def _run_etcdctl( command: str, subcommand: str | None, endpoints: str, - output_format: str | None, - ) -> str: + output_format: str | None = "simple", + ) -> str | None: """Execute `etcdctl` command via subprocess. The list of arguments will be extended once authentication/encryption is implemented. @@ -105,8 +103,8 @@ def _run_etcdctl( Returns: The output of the subprocess-command as a string. In case of error, this will - return an empty string. It will not raise an error in order to leave error handling - up to the caller. Depending on what command is executed, the ways of handling errors + return `None`. It will not raise an error in order to leave error handling up + to the caller. Depending on what command is executed, the ways of handling errors might differ. """ try: @@ -124,6 +122,6 @@ def _run_etcdctl( ).stdout except subprocess.CalledProcessError as e: logger.warning(e) - return "" + return None return result From b8ea77f1fcea19b644b2a9ceb7988f6ff56d5918 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Tue, 26 Nov 2024 09:55:20 +0000 Subject: [PATCH 28/50] Move `EtcdClient` to a separate file --- src/common/client.py | 79 +++++++++++++++++++++++++++++++++++++++++ src/managers/cluster.py | 71 +----------------------------------- 2 files changed, 80 insertions(+), 70 deletions(-) create mode 100644 src/common/client.py diff --git a/src/common/client.py b/src/common/client.py new file mode 100644 index 0000000..8d783ab --- /dev/null +++ b/src/common/client.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""EtcdClient utility class to connect to etcd server and execute commands with etcdctl.""" + +import json +import logging +import subprocess + +logger = logging.getLogger(__name__) + + +class EtcdClient: + """Handle etcd client connections and run etcdctl commands.""" + + def __init__( + self, + client_url: str, + ): + self.client_url = client_url + + def get_endpoint_status(self) -> dict: + """Run the `endpoint status` command and return the result as dict.""" + endpoint_status = {} + if result := self._run_etcdctl( + command="endpoint", + subcommand="status", + endpoints=self.client_url, + output_format="json", + ): + endpoint_status = json.loads(result)[0] + + return endpoint_status + + def _run_etcdctl( + self, + command: str, + subcommand: str | None, + endpoints: str, + output_format: str | None = "simple", + ) -> 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. + + 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 + output_format: set the output format (fields, json, protobuf, simple, table) + ... + + Returns: + The output of the subprocess-command as a string. In case of error, this will + return `None`. It will not raise an error in order to leave error handling up + to the caller. Depending on what command is executed, the ways of handling errors + might differ. + """ + try: + result = subprocess.run( + args=[ + "etcdctl", + command, + subcommand, + f"--endpoints={endpoints}", + f"-w={output_format}", + ], + check=True, + capture_output=True, + text=True, + ).stdout + except subprocess.CalledProcessError as e: + logger.warning(e) + return None + + return result diff --git a/src/managers/cluster.py b/src/managers/cluster.py index d63bf7e..01dee2f 100644 --- a/src/managers/cluster.py +++ b/src/managers/cluster.py @@ -4,11 +4,10 @@ """Manager for all cluster/quorum/rbac related tasks.""" -import json import logging import socket -import subprocess +from common.client import EtcdClient from core.cluster import ClusterState logger = logging.getLogger(__name__) @@ -57,71 +56,3 @@ def get_leader(self) -> str | None: logger.warning("No raft leader found in cluster.") return None - - -class EtcdClient: - """Handle etcd client connections and run etcdctl commands.""" - - def __init__( - self, - client_url: str, - ): - self.client_url = client_url - - def get_endpoint_status(self) -> dict: - """Run the `endpoint status` command and return the result as dict.""" - endpoint_status = {} - if result := self._run_etcdctl( - command="endpoint", - subcommand="status", - endpoints=self.client_url, - output_format="json", - ): - endpoint_status = json.loads(result)[0] - - return endpoint_status - - def _run_etcdctl( - self, - command: str, - subcommand: str | None, - endpoints: str, - output_format: str | None = "simple", - ) -> 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. - - 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 - output_format: set the output format (fields, json, protobuf, simple, table) - ... - - Returns: - The output of the subprocess-command as a string. In case of error, this will - return `None`. It will not raise an error in order to leave error handling up - to the caller. Depending on what command is executed, the ways of handling errors - might differ. - """ - try: - result = subprocess.run( - args=[ - "etcdctl", - command, - subcommand, - f"--endpoints={endpoints}", - f"-w={output_format}", - ], - check=True, - capture_output=True, - text=True, - ).stdout - except subprocess.CalledProcessError as e: - logger.warning(e) - return None - - return result From 01fe2abfc146c43a968adff60115016171e07507 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Thu, 28 Nov 2024 16:58:03 +0000 Subject: [PATCH 29/50] create internal admin user `on_install` --- src/core/cluster.py | 6 ++++-- src/core/models.py | 10 +++++++++- src/core/workload.py | 11 +++++++++++ src/events/etcd.py | 10 +++++++++- src/literals.py | 5 +++-- tests/unit/test_charm.py | 13 ++++++++++++- 6 files changed, 48 insertions(+), 7 deletions(-) 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..3f1f921 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -10,7 +10,7 @@ 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__) @@ -119,3 +119,11 @@ 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] | None: + """Retrieve the credentials for the internal admin user.""" + if password := self.relation_data.get(f"{INTERNAL_USER}-password"): + return {INTERNAL_USER: password} + + return None 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..4d89246 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -17,7 +17,7 @@ RelationJoinedEvent, ) -from literals import PEER_RELATION, Status +from literals import INTERNAL_USER, PEER_RELATION, Status if TYPE_CHECKING: from charm import EtcdOperatorCharm @@ -58,6 +58,14 @@ def _on_install(self, event: ops.InstallEvent) -> None: self.charm.set_status(Status.SERVICE_NOT_INSTALLED) 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_start(self, event: ops.StartEvent) -> None: """Handle start event.""" # Make sure all planned units have joined the peer relation before starting the cluster diff --git a/src/literals.py b/src/literals.py index eb18142..2be2210 100644 --- a/src/literals.py +++ b/src/literals.py @@ -13,14 +13,15 @@ SNAP_NAME = "etcd" SNAP_REVISION = 233 SNAP_SERVICE = "etcd" -# this path will be updated when we switch to charmed-etcd snap -# it's the current config path for the legacy-etcd snap CONFIG_FILE = "/var/snap/etcd/common/etcd.conf.yml" PEER_RELATION = "etcd-peers" CLIENT_PORT = 2379 PEER_PORT = 2380 +INTERNAL_USER = "root" +SECRETS_APP = ["root-password"] + DebugLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"] SUBSTRATES = Literal["vm", "k8s"] SUBSTRATE = "vm" diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 81c1cdc..fb3914b 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -8,7 +8,7 @@ from ops import testing from charm import EtcdOperatorCharm -from literals import CLIENT_PORT, PEER_RELATION +from literals import CLIENT_PORT, INTERNAL_USER, PEER_RELATION def test_install_failure_blocked_status(): @@ -20,6 +20,17 @@ 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) + with patch("workload.EtcdWorkload.install", return_value=True): + state_out = ctx.run(ctx.on.install(), state_in) + secret_out = state_out.get_secret(label="etcd-peers.charmed-etcd-operator.app") + assert secret_out.latest_content.get(f"{INTERNAL_USER}-password") + + def test_start(): ctx = testing.Context(EtcdOperatorCharm) state_in = testing.State() From a6abdf031db87d43314f74fa3f9810fb482746b7 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Fri, 29 Nov 2024 07:15:47 +0000 Subject: [PATCH 30/50] adjust unit test --- tests/unit/test_charm.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index fb3914b..a7a489a 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -2,14 +2,19 @@ # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. +from pathlib import Path from unittest.mock import patch +import yaml import ops from ops import testing from charm import EtcdOperatorCharm from literals import CLIENT_PORT, INTERNAL_USER, PEER_RELATION +METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) +APP_NAME = METADATA["name"] + def test_install_failure_blocked_status(): ctx = testing.Context(EtcdOperatorCharm) @@ -27,7 +32,7 @@ def test_internal_user_creation(): state_in = testing.State(relations={relation}, leader=True) with patch("workload.EtcdWorkload.install", return_value=True): state_out = ctx.run(ctx.on.install(), state_in) - secret_out = state_out.get_secret(label="etcd-peers.charmed-etcd-operator.app") + secret_out = state_out.get_secret(label=f"{PEER_RELATION}.{APP_NAME}.app") assert secret_out.latest_content.get(f"{INTERNAL_USER}-password") From 8bc9e138ceadfd8260be1d9c5c52c7600e115e1f Mon Sep 17 00:00:00 2001 From: reneradoi Date: Fri, 29 Nov 2024 11:30:32 +0000 Subject: [PATCH 31/50] add username/password to `EtcdClient`, add methods for adding users and enabling auth to `EtcdClient`, enable auth on startup --- src/common/client.py | 59 ++++++++++++++++++++++++++++++++++++++++ src/common/exceptions.py | 23 ++++++++++++++++ src/events/etcd.py | 27 ++++++++++++++---- src/literals.py | 3 ++ src/managers/cluster.py | 30 ++++++++++++++------ tests/unit/test_charm.py | 2 +- 6 files changed, 130 insertions(+), 14 deletions(-) create mode 100644 src/common/exceptions.py diff --git a/src/common/client.py b/src/common/client.py index 8d783ab..0337dd4 100644 --- a/src/common/client.py +++ b/src/common/client.py @@ -8,6 +8,8 @@ import logging import subprocess +from common.exceptions import EtcdAuthNotEnabledError, EtcdUserNotCreatedError + logger = logging.getLogger(__name__) @@ -16,9 +18,13 @@ class EtcdClient: def __init__( self, + username, + password, 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.""" @@ -27,17 +33,62 @@ def get_endpoint_status(self) -> dict: command="endpoint", subcommand="status", endpoints=self.client_url, + username=self.user, + password=self.password, output_format="json", ): endpoint_status = json.loads(result)[0] return endpoint_status + def add_admin_user(self): + """Add the internal admin with password. Raise if not successful.""" + if result := self._run_etcdctl( + command="user", + subcommand="add", + endpoints=self.client_url, + new_user=f"{self.user}:{self.password}", + ): + logger.debug(result) + else: + raise EtcdUserNotCreatedError(f"Failed to add user {self.user}.") + + def add_client_user(self): + """Add non-admin user with `CommonName` based authentication`.""" + if result := self._run_etcdctl( + command="user", + subcommand="add", + endpoints=self.client_url, + new_user=self.user, + no_password=True, + ): + logger.debug(result) + else: + raise EtcdUserNotCreatedError(f"Failed to add user {self.user}.") + + def enable_auth(self): + """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( self, command: str, subcommand: str | None, endpoints: str, + # We need to be able to run `etcdctl` with empty user/pw + # otherwise it will error if auth is not yet enabled + # this is relevant for `user add` and `auth enable` commands + username: str | None, + password: str | None, + new_user: str | None, + no_password: bool = False, output_format: str | None = "simple", ) -> str | None: """Execute `etcdctl` command via subprocess. @@ -50,6 +101,10 @@ def _run_etcdctl( 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 + username: user for authentication + password: password for authentication + new_user: username (and password, in case of admin user) to be added to etcd + no_password: add a new user with the --no-password option for CN based authentication output_format: set the output format (fields, json, protobuf, simple, table) ... @@ -65,7 +120,11 @@ def _run_etcdctl( "etcdctl", command, subcommand, + new_user if new_user else "", f"--endpoints={endpoints}", + f"--user={username}", + f"--password={password}", + "--no-password" if no_password else "", f"-w={output_format}", ], check=True, diff --git a/src/common/exceptions.py b/src/common/exceptions.py new file mode 100644 index 0000000..f0a5e48 --- /dev/null +++ b/src/common/exceptions.py @@ -0,0 +1,23 @@ +#!/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.""" + + pass + + +class EtcdUserNotCreatedError(Exception): + """Custom Exception if user could not be added to etcd cluster.""" + + pass + + +class EtcdAuthNotEnabledError(Exception): + """Custom Exception if authentication could not be enabled in the etcd cluster.""" + + pass diff --git a/src/events/etcd.py b/src/events/etcd.py index 4d89246..b41c3c2 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -17,6 +17,11 @@ RelationJoinedEvent, ) +from common.exceptions import ( + EtcdAuthNotEnabledError, + EtcdUserNotCreatedError, + RaftLeaderNotFoundError, +) from literals import INTERNAL_USER, PEER_RELATION, Status if TYPE_CHECKING: @@ -58,10 +63,7 @@ def _on_install(self, event: ops.InstallEvent) -> None: self.charm.set_status(Status.SERVICE_NOT_INSTALLED) return - if ( - self.charm.unit.is_leader() - and not self.charm.state.cluster.internal_user_credentials - ): + 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()} ) @@ -82,6 +84,18 @@ def _on_start(self, event: ops.StartEvent) -> None: self.charm.workload.start() + if self.charm.unit.is_leader(): + try: + self.charm.cluster_manager.enable_authentication() + except ( + RaftLeaderNotFoundError, + EtcdAuthNotEnabledError, + EtcdUserNotCreatedError, + ) 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: @@ -110,8 +124,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.""" diff --git a/src/literals.py b/src/literals.py index 2be2210..91540b4 100644 --- a/src/literals.py +++ b/src/literals.py @@ -39,6 +39,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..939653b 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, + EtcdUserNotCreatedError, + 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 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,16 @@ 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): + """Enable the etcd admin user and authentication.""" + try: + endpoint = self.get_leader() + client = EtcdClient(client_url=endpoint) + client.add_admin_user() + client.enable_auth() + except (RaftLeaderNotFoundError, EtcdAuthNotEnabledError, EtcdUserNotCreatedError): + raise diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index a7a489a..9345654 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -5,8 +5,8 @@ from pathlib import Path from unittest.mock import patch -import yaml import ops +import yaml from ops import testing from charm import EtcdOperatorCharm From 3025864bb8e0774a8a805d2fe897344653a782ce Mon Sep 17 00:00:00 2001 From: reneradoi Date: Fri, 29 Nov 2024 14:55:21 +0000 Subject: [PATCH 32/50] use `typing.Optional` for optional arguments --- src/common/client.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/common/client.py b/src/common/client.py index e3a46f0..84e0e52 100644 --- a/src/common/client.py +++ b/src/common/client.py @@ -7,6 +7,7 @@ import json import logging import subprocess +from typing import Optional from common.exceptions import EtcdAuthNotEnabledError, EtcdUserNotCreatedError @@ -80,16 +81,16 @@ def enable_auth(self): def _run_etcdctl( self, command: str, - subcommand: str | None, endpoints: str, + subcommand: Optional[str] = None, # We need to be able to run `etcdctl` with empty user/pw # otherwise it will error if auth is not yet enabled # this is relevant for `user add` and `auth enable` commands - username: str | None, - password: str | None, - new_user: str | None, - no_password: bool = False, - output_format: str | None = "simple", + username: Optional[str] = None, + password: Optional[str] = None, + new_user: Optional[str] = None, + no_password: Optional[bool] = False, + output_format: Optional[str] = "simple", ) -> str | None: """Execute `etcdctl` command via subprocess. @@ -120,7 +121,7 @@ def _run_etcdctl( "etcdctl", command, subcommand, - new_user if new_user else "", + new_user, f"--endpoints={endpoints}", f"--user={username}", f"--password={password}", From bc351ff324604019553289c7b37fed26f1bb2d5f Mon Sep 17 00:00:00 2001 From: reneradoi Date: Fri, 29 Nov 2024 16:19:00 +0000 Subject: [PATCH 33/50] adjust handling of admin user and password --- src/common/client.py | 17 ++++++++++------- src/core/models.py | 4 ++-- src/events/etcd.py | 10 +++++----- src/managers/cluster.py | 6 ++++-- tests/unit/test_charm.py | 7 +++---- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/common/client.py b/src/common/client.py index 84e0e52..1438a9f 100644 --- a/src/common/client.py +++ b/src/common/client.py @@ -38,7 +38,10 @@ def get_endpoint_status(self) -> dict: password=self.password, output_format="json", ): - endpoint_status = json.loads(result)[0] + try: + endpoint_status = json.loads(result)[0] + except json.JSONDecodeError: + pass return endpoint_status @@ -120,11 +123,11 @@ def _run_etcdctl( args=[ "etcdctl", command, - subcommand, - new_user, + subcommand if subcommand else "", + new_user if new_user else "", f"--endpoints={endpoints}", - f"--user={username}", - f"--password={password}", + f"--user={username}" if username else "", + f"--password={password}" if password else "", "--no-password" if no_password else "", f"-w={output_format}", ], @@ -132,8 +135,8 @@ def _run_etcdctl( capture_output=True, text=True, ).stdout.strip() - except subprocess.CalledProcessError as e: - logger.warning(e) + except subprocess.CalledProcessError: + logger.warning(f"etcdctl {command} command failed.") return None return result diff --git a/src/core/models.py b/src/core/models.py index 3f1f921..9f0369a 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -121,9 +121,9 @@ def initial_cluster_state(self) -> str: return self.relation_data.get("initial_cluster_state", "") @property - def internal_user_credentials(self) -> dict[str, str] | None: + 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 None + return {} diff --git a/src/events/etcd.py b/src/events/etcd.py index b41c3c2..04640c6 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -63,11 +63,6 @@ def _on_install(self, event: ops.InstallEvent) -> None: self.charm.set_status(Status.SERVICE_NOT_INSTALLED) 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_start(self, event: ops.StartEvent) -> None: """Handle start event.""" # Make sure all planned units have joined the peer relation before starting the cluster @@ -136,6 +131,11 @@ 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(): diff --git a/src/managers/cluster.py b/src/managers/cluster.py index 939653b..a8f5ae0 100644 --- a/src/managers/cluster.py +++ b/src/managers/cluster.py @@ -25,7 +25,7 @@ class ClusterManager: def __init__(self, state: ClusterState): self.state = state self.admin_user = INTERNAL_USER - self.admin_password = self.state.cluster.internal_user_credentials + 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]: @@ -65,7 +65,9 @@ def enable_authentication(self): """Enable the etcd admin user and authentication.""" try: endpoint = self.get_leader() - client = EtcdClient(client_url=endpoint) + client = EtcdClient( + username=self.admin_user, password=self.admin_password, client_url=endpoint + ) client.add_admin_user() client.enable_auth() except (RaftLeaderNotFoundError, EtcdAuthNotEnabledError, EtcdUserNotCreatedError): diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 9345654..9177de6 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -30,10 +30,9 @@ def test_internal_user_creation(): relation = testing.PeerRelation(id=1, endpoint=PEER_RELATION) state_in = testing.State(relations={relation}, leader=True) - with patch("workload.EtcdWorkload.install", return_value=True): - state_out = ctx.run(ctx.on.install(), 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") + 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(): From 924e99d65886ff36e7c0ae8d3dab9c1fdf6e1d19 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Mon, 2 Dec 2024 16:40:29 +0000 Subject: [PATCH 34/50] add authentication to EtcdClient --- src/common/client.py | 66 +++++++++++++++++++---------------------- src/events/etcd.py | 6 +--- src/managers/cluster.py | 9 +++--- 3 files changed, 37 insertions(+), 44 deletions(-) diff --git a/src/common/client.py b/src/common/client.py index 1438a9f..d29a1ef 100644 --- a/src/common/client.py +++ b/src/common/client.py @@ -10,6 +10,7 @@ from typing import Optional from common.exceptions import EtcdAuthNotEnabledError, EtcdUserNotCreatedError +from literals import INTERNAL_USER logger = logging.getLogger(__name__) @@ -34,8 +35,6 @@ def get_endpoint_status(self) -> dict: command="endpoint", subcommand="status", endpoints=self.client_url, - username=self.user, - password=self.password, output_format="json", ): try: @@ -45,26 +44,15 @@ def get_endpoint_status(self) -> dict: return endpoint_status - def add_admin_user(self): - """Add the internal admin with password. Raise if not successful.""" + def add_user(self, username: str): + """Add a user to etcd.""" if result := self._run_etcdctl( command="user", subcommand="add", endpoints=self.client_url, - new_user=f"{self.user}:{self.password}", - ): - logger.debug(result) - else: - raise EtcdUserNotCreatedError(f"Failed to add user {self.user}.") - - def add_client_user(self): - """Add non-admin user with `CommonName` based authentication`.""" - if result := self._run_etcdctl( - command="user", - subcommand="add", - endpoints=self.client_url, - new_user=self.user, - no_password=True, + new_user=username, + # only admin user is added with password, all others require `CommonName` based auth + new_user_password=self.password if username == INTERNAL_USER else "", ): logger.debug(result) else: @@ -86,13 +74,13 @@ def _run_etcdctl( command: str, endpoints: str, subcommand: Optional[str] = None, - # We need to be able to run `etcdctl` with empty user/pw + # 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 username: Optional[str] = None, password: Optional[str] = None, new_user: Optional[str] = None, - no_password: Optional[bool] = False, + new_user_password: Optional[str] = None, output_format: Optional[str] = "simple", ) -> str | None: """Execute `etcdctl` command via subprocess. @@ -107,8 +95,8 @@ def _run_etcdctl( endpoints: str-formatted list of endpoints to run the command against username: user for authentication password: password for authentication - new_user: username (and password, in case of admin user) to be added to etcd - no_password: add a new user with the --no-password option for CN based authentication + new_user: username to be added to etcd + new_user_password: password to be set for the new user output_format: set the output format (fields, json, protobuf, simple, table) ... @@ -119,24 +107,32 @@ def _run_etcdctl( might differ. """ try: + args = ["etcdctl", command] + if subcommand: + args.append(subcommand) + if new_user: + args.append(new_user) + if new_user_password == "": + args.append("--no-password=True") + elif new_user_password: + args.append(f"--new-user-password={new_user_password}") + if endpoints: + args.append(f"--endpoints={endpoints}") + if username: + args.append(f"--user={username}") + if password: + args.append(f"--password={password}") + if output_format: + args.append(f"-w={output_format}") + result = subprocess.run( - args=[ - "etcdctl", - command, - subcommand if subcommand else "", - new_user if new_user else "", - f"--endpoints={endpoints}", - f"--user={username}" if username else "", - f"--password={password}" if password else "", - "--no-password" if no_password else "", - f"-w={output_format}", - ], + args=args, check=True, capture_output=True, text=True, ).stdout.strip() - except subprocess.CalledProcessError: - logger.warning(f"etcdctl {command} command failed.") + except subprocess.CalledProcessError as e: + logger.warning(f"etcdctl {command} command failed: {e}") return None return result diff --git a/src/events/etcd.py b/src/events/etcd.py index 04640c6..c826dbb 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -82,11 +82,7 @@ def _on_start(self, event: ops.StartEvent) -> None: if self.charm.unit.is_leader(): try: self.charm.cluster_manager.enable_authentication() - except ( - RaftLeaderNotFoundError, - EtcdAuthNotEnabledError, - EtcdUserNotCreatedError, - ) as e: + except (EtcdAuthNotEnabledError, EtcdUserNotCreatedError) as e: logger.error(e) self.charm.set_status(Status.AUTHENTICATION_NOT_ENABLED) return diff --git a/src/managers/cluster.py b/src/managers/cluster.py index a8f5ae0..a6b3d52 100644 --- a/src/managers/cluster.py +++ b/src/managers/cluster.py @@ -64,11 +64,12 @@ def get_leader(self) -> str | None: def enable_authentication(self): """Enable the etcd admin user and authentication.""" try: - endpoint = self.get_leader() client = EtcdClient( - username=self.admin_user, password=self.admin_password, client_url=endpoint + username=self.admin_user, + password=self.admin_password, + client_url=self.state.unit_server.client_url, ) - client.add_admin_user() + client.add_user(username=self.admin_user) client.enable_auth() - except (RaftLeaderNotFoundError, EtcdAuthNotEnabledError, EtcdUserNotCreatedError): + except (EtcdAuthNotEnabledError, EtcdUserNotCreatedError): raise From 355b6b9e04fae17c18478dac672fffbe09e52b4a Mon Sep 17 00:00:00 2001 From: reneradoi Date: Tue, 3 Dec 2024 09:10:55 +0000 Subject: [PATCH 35/50] adjust logging in case of subprocess errors --- pyproject.toml | 2 +- src/common/client.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ece49a7..b4b7a44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,7 @@ lint.extend-ignore = [ "D409", "D413", ] -lint.ignore = ["E501", "D107"] +lint.ignore = ["E501", "D107", "C901"] extend-exclude = ["__pycache__", "*.egg_info"] lint.per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]} diff --git a/src/common/client.py b/src/common/client.py index d29a1ef..ba2c506 100644 --- a/src/common/client.py +++ b/src/common/client.py @@ -132,7 +132,10 @@ def _run_etcdctl( text=True, ).stdout.strip() except subprocess.CalledProcessError as e: - logger.warning(f"etcdctl {command} command failed: {e}") + logger.error(f"etcdctl {command} command failed: {e.returncode}, {e.stderr}") + return None + except subprocess.TimeoutExpired as e: + logger.error(f"Timed out running etcdctl: {e.stderr}") return None return result From d1dc6859df1ae0316d3b984674d1d45377de6f3c Mon Sep 17 00:00:00 2001 From: reneradoi Date: Tue, 3 Dec 2024 09:23:22 +0000 Subject: [PATCH 36/50] fix merging main into branch --- src/common/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/common/client.py b/src/common/client.py index e2aaeb3..769122a 100644 --- a/src/common/client.py +++ b/src/common/client.py @@ -72,7 +72,6 @@ def enable_auth(self): def _run_etcdctl( self, command: str, - subcommand: str | None, endpoints: str, subcommand: Optional[str] = None, # We need to be able to run `etcdctl` without user/pw @@ -133,7 +132,9 @@ def _run_etcdctl( text=True, ).stdout.strip() except subprocess.CalledProcessError as e: - logger.error(f"etcdctl {command} command failed: {e.returncode}, {e.stderr}") + 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}") From a7d0459f6ede4fbbe1e5cdb857bc806505d30252 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Tue, 3 Dec 2024 12:12:25 +0000 Subject: [PATCH 37/50] implement `get-password` and `set-password` actions --- actions.yaml | 21 ++++++++++++ src/charm.py | 2 ++ src/common/client.py | 72 ++++++++++++++++++++++++-------------- src/common/exceptions.py | 4 +-- src/events/actions.py | 74 ++++++++++++++++++++++++++++++++++++++++ src/events/etcd.py | 4 +-- src/managers/cluster.py | 18 ++++++++-- 7 files changed, 162 insertions(+), 33 deletions(-) create mode 100644 actions.yaml create mode 100644 src/events/actions.py diff --git a/actions.yaml b/actions.yaml new file mode 100644 index 0000000..b1295ad --- /dev/null +++ b/actions.yaml @@ -0,0 +1,21 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +set-password: + description: Change the admin user's password, which is used by charm. It is for internal charm users and SHOULD NOT be used by applications. + params: + username: + type: string + description: The username, the default value 'root'. Possible values - 'root'. + default: root + password: + type: string + description: The password will be auto-generated if this option is not set. + +get-password: + description: Fetch the admin user's password and CA chain, which is used by charm. It is for internal charm users and SHOULD NOT be used by applications. + params: + username: + type: string + description: The username, the default value 'root'. Possible values - 'root'. + default: root \ No newline at end of file diff --git a/src/charm.py b/src/charm.py index 3c463b2..0e36abf 100755 --- a/src/charm.py +++ b/src/charm.py @@ -10,6 +10,7 @@ from ops import StatusBase from core.cluster import ClusterState +from events.actions import ActionEvents from events.etcd import EtcdEvents from literals import SUBSTRATE, DebugLevel, Status from managers.cluster import ClusterManager @@ -35,6 +36,7 @@ def __init__(self, *args): # --- EVENT HANDLERS --- self.etcd_events = EtcdEvents(self) + self.action_events = ActionEvents(self) def set_status(self, key: Status) -> None: """Set charm status.""" diff --git a/src/common/client.py b/src/common/client.py index 769122a..e6bbf67 100644 --- a/src/common/client.py +++ b/src/common/client.py @@ -9,7 +9,7 @@ import subprocess from typing import Optional -from common.exceptions import EtcdAuthNotEnabledError, EtcdUserNotCreatedError +from common.exceptions import EtcdAuthNotEnabledError, EtcdUserManagementError from literals import INTERNAL_USER, SNAP_NAME logger = logging.getLogger(__name__) @@ -44,21 +44,36 @@ def get_endpoint_status(self) -> dict: return endpoint_status - def add_user(self, username: str): + 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, - new_user=username, + user=username, # only admin user is added with password, all others require `CommonName` based auth - new_user_password=self.password if username == INTERNAL_USER else "", + user_password=self.password if username == INTERNAL_USER else "", ): logger.debug(result) else: - raise EtcdUserNotCreatedError(f"Failed to add user {self.user}.") + raise EtcdUserManagementError(f"Failed to add user {self.user}.") - def enable_auth(self): + 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", @@ -77,28 +92,30 @@ def _run_etcdctl( # 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 - username: Optional[str] = None, - password: Optional[str] = None, - new_user: Optional[str] = None, - new_user_password: Optional[str] = None, + auth_username: Optional[str] = None, + auth_password: Optional[str] = None, + user: Optional[str] = None, + user_password: Optional[str] = None, output_format: Optional[str] = "simple", + use_input: Optional[str] = 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 - username: user for authentication - password: password for authentication - new_user: username to be added to etcd - new_user_password: password to be set for the new user + 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 @@ -110,26 +127,29 @@ def _run_etcdctl( args = [f"{SNAP_NAME}.etcdctl", command] if subcommand: args.append(subcommand) - if new_user: - args.append(new_user) - if new_user_password == "": + if user: + args.append(user) + if user_password == "": args.append("--no-password=True") - elif new_user_password: - args.append(f"--new-user-password={new_user_password}") + elif user_password: + args.append(f"--new-user-password={user_password}") if endpoints: args.append(f"--endpoints={endpoints}") - if username: - args.append(f"--user={username}") - if password: - args.append(f"--password={password}") + 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=args, check=True, capture_output=True, text=True, + input=use_input if use_input else "", ).stdout.strip() except subprocess.CalledProcessError as e: logger.error( diff --git a/src/common/exceptions.py b/src/common/exceptions.py index f0a5e48..a1d81bb 100644 --- a/src/common/exceptions.py +++ b/src/common/exceptions.py @@ -11,8 +11,8 @@ class RaftLeaderNotFoundError(Exception): pass -class EtcdUserNotCreatedError(Exception): - """Custom Exception if user could not be added to etcd cluster.""" +class EtcdUserManagementError(Exception): + """Custom Exception if user could not be added or updated in etcd cluster.""" pass diff --git a/src/events/actions.py b/src/events/actions.py new file mode 100644 index 0000000..b35a561 --- /dev/null +++ b/src/events/actions.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Event handlers for Juju Actions.""" + +import logging +from typing import TYPE_CHECKING + +from ops.charm import ActionEvent +from ops.framework import Object + +from common.exceptions import EtcdUserManagementError +from literals import INTERNAL_USER + +if TYPE_CHECKING: + from charm import EtcdOperatorCharm + +logger = logging.getLogger(__name__) + + +class ActionEvents(Object): + """Handle all events for user-related Juju Actions.""" + + def __init__(self, charm: "EtcdOperatorCharm"): + super().__init__(charm, key="action_events") + self.charm = charm + + self.framework.observe(self.charm.on.set_password_action, self._on_set_password) + self.framework.observe(self.charm.on.get_password_action, self._on_get_password) + + def _on_get_password(self, event: ActionEvent) -> None: + """Return the password and certificate chain for the internal admin user.""" + username = event.params.get("username") + if username != INTERNAL_USER: + event.fail(f"Action only allowed for user {INTERNAL_USER}.") + return + + if not self.charm.state.cluster.internal_user_credentials: + event.fail("User credentials not created yet.") + return + + event.set_results( + { + "username": username, + "password": self.charm.state.cluster.internal_user_credentials[INTERNAL_USER], + "ca-chain": "...", + } + ) + + def _on_set_password(self, event: ActionEvent) -> None: + """Handle the `set-password` action for the internal admin user. + + If no password is provided, generate one. + """ + username = event.params.get("username") + if username != INTERNAL_USER: + event.fail(f"Action only allowed for user {INTERNAL_USER}.") + return + + if not self.charm.unit.is_leader(): + event.fail("Action can only be run on the leader unit.") + return + + new_password = event.params.get("password") or self.charm.workload.generate_password() + + try: + self.charm.cluster_manager.update_credentials(username=username, password=new_password) + self.charm.state.cluster.update({f"{INTERNAL_USER}-password": new_password}) + except EtcdUserManagementError as e: + logger.error(e) + event.fail(e) + + event.set_results({f"{username}-password": new_password}) diff --git a/src/events/etcd.py b/src/events/etcd.py index c826dbb..735d416 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -19,7 +19,7 @@ from common.exceptions import ( EtcdAuthNotEnabledError, - EtcdUserNotCreatedError, + EtcdUserManagementError, RaftLeaderNotFoundError, ) from literals import INTERNAL_USER, PEER_RELATION, Status @@ -82,7 +82,7 @@ def _on_start(self, event: ops.StartEvent) -> None: if self.charm.unit.is_leader(): try: self.charm.cluster_manager.enable_authentication() - except (EtcdAuthNotEnabledError, EtcdUserNotCreatedError) as e: + except (EtcdAuthNotEnabledError, EtcdUserManagementError) as e: logger.error(e) self.charm.set_status(Status.AUTHENTICATION_NOT_ENABLED) return diff --git a/src/managers/cluster.py b/src/managers/cluster.py index a6b3d52..324d91f 100644 --- a/src/managers/cluster.py +++ b/src/managers/cluster.py @@ -10,7 +10,7 @@ from common.client import EtcdClient from common.exceptions import ( EtcdAuthNotEnabledError, - EtcdUserNotCreatedError, + EtcdUserManagementError, RaftLeaderNotFoundError, ) from core.cluster import ClusterState @@ -61,7 +61,7 @@ def get_leader(self) -> str | None: return None - def enable_authentication(self): + def enable_authentication(self) -> None: """Enable the etcd admin user and authentication.""" try: client = EtcdClient( @@ -71,5 +71,17 @@ def enable_authentication(self): ) client.add_user(username=self.admin_user) client.enable_auth() - except (EtcdAuthNotEnabledError, EtcdUserNotCreatedError): + 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 From e8e677fda95ab845df1a885bb3d68b726acee144 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Tue, 3 Dec 2024 14:14:23 +0000 Subject: [PATCH 38/50] fix integration test by adding authentication --- tests/integration/helpers.py | 36 +++++++++++++++++++++++++++++---- tests/integration/test_charm.py | 22 ++++++++++++++++++-- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 80a5184..6e54724 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -18,17 +18,32 @@ 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, + user: str, + password: str, + key: str, + value: str, +) -> str: """Write data to etcd using `etcdctl` via `juju ssh`.""" - etcd_command = f"{SNAP_NAME}.etcdctl put {key} {value} --endpoints={endpoints}" + etcd_command = f"{SNAP_NAME}.etcdctl put {key} {value} --endpoints={endpoints} --user={user} --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, + user: str, + password: str, + key: str, +) -> str: """Read data from etcd using `etcdctl` via `juju ssh`.""" - etcd_command = f"{SNAP_NAME}.etcdctl get {key} --endpoints={endpoints}" + etcd_command = f"{SNAP_NAME}.etcdctl get {key} --endpoints={endpoints} --user={user} --password={password}" juju_command = f"juju ssh --model={model} {unit} {etcd_command}" return subprocess.getoutput(juju_command).split("\n")[1] @@ -59,3 +74,16 @@ 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_user_password(ops_test: OpsTest, user: str, unit: str) -> str: + """Use the action to retrieve the password for a user. + + Return: + String with the password stored on the peer relation databag. + """ + action = await ops_test.model.units.get(unit).run_action( + action_name="get-password", params={"username": user} + ) + credentials = await action.wait() + return credentials.results["password"] diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 22da2ff..249bd49 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 + from .helpers import ( APP_NAME, get_cluster_endpoints, get_cluster_members, get_juju_leader_unit_name, get_key, + get_user_password, put_key, ) @@ -45,7 +48,22 @@ 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 + password = await get_user_password(ops_test, user=INTERNAL_USER, unit=leader_unit) 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 + 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 + ) From 31f7864e68b7231a094930679bee097f6040d660 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Wed, 4 Dec 2024 14:01:53 +0000 Subject: [PATCH 39/50] add unit test coverage --- tests/unit/test_actions.py | 71 ++++++++++++++++++++++++++++++++++++++ tests/unit/test_charm.py | 27 +++++++++++++-- 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_actions.py diff --git a/tests/unit/test_actions.py b/tests/unit/test_actions.py new file mode 100644 index 0000000..b18af42 --- /dev/null +++ b/tests/unit/test_actions.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +from pathlib import Path +from subprocess import CompletedProcess +from unittest.mock import patch + +import yaml +from ops import testing +from ops.testing import ActionFailed +from pytest import raises + +from charm import EtcdOperatorCharm +from literals import INTERNAL_USER, PEER_RELATION + +METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) +APP_NAME = METADATA["name"] + + +def test_get_password(): + ctx = testing.Context(EtcdOperatorCharm) + relation = testing.PeerRelation(id=1, endpoint=PEER_RELATION) + + # make sure admin credentials are created initially + state_in = testing.State(relations={relation}, leader=True) + state_out = ctx.run(ctx.on.leader_elected(), state_in) + + ctx.run(ctx.on.action("get-password", params={"username": f"{INTERNAL_USER}"}), state_out) + assert ctx.action_results.get("username") == INTERNAL_USER + assert ctx.action_results.get("password") + assert ctx.action_results.get("ca-chain") + + +def test_set_password(): + ctx = testing.Context(EtcdOperatorCharm) + relation = testing.PeerRelation(id=1, endpoint=PEER_RELATION) + password = "test_pwd" + + # this action is not allowed on non-leader units + state_in = testing.State(relations={relation}, leader=False) + with raises(ActionFailed) as error: + ctx.run( + ctx.on.action( + "set-password", params={"username": INTERNAL_USER, "password": password} + ), + state_in, + ) + assert error.value.message == "Action can only be run on the leader unit." + + # make sure a password cannot set for any other than the admin user + state_in = testing.State(relations={relation}, leader=True) + with raises(ActionFailed) as error: + ctx.run( + ctx.on.action("set-password", params={"username": "my_user", "password": password}), + state_in, + ) + assert error.value.message == f"Action only allowed for user {INTERNAL_USER}." + + # update the admin user's password + state_in = testing.State(relations={relation}, leader=True) + with patch( + "subprocess.run", return_value=CompletedProcess(returncode=0, args=[], stdout="OK") + ): + ctx.run( + ctx.on.action( + "set-password", params={"username": INTERNAL_USER, "password": password} + ), + state_in, + ) + assert ctx.action_results.get(f"{INTERNAL_USER}-password") == password diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 9177de6..df2ef6d 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -3,6 +3,7 @@ # See LICENSE file for licensing details. from pathlib import Path +from subprocess import CalledProcessError, CompletedProcess from unittest.mock import patch import ops @@ -44,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") @@ -57,7 +57,28 @@ 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() @@ -67,7 +88,7 @@ def test_start(): 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") From fb37c44d5a4c3843b8350febbcdd1139925168ac Mon Sep 17 00:00:00 2001 From: reneradoi Date: Wed, 4 Dec 2024 17:11:33 +0000 Subject: [PATCH 40/50] add integration test coverage --- src/common/client.py | 4 ++-- tests/integration/helpers.py | 22 +++++++++++++----- tests/integration/test_charm.py | 40 +++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/common/client.py b/src/common/client.py index e6bbf67..994975c 100644 --- a/src/common/client.py +++ b/src/common/client.py @@ -20,8 +20,8 @@ class EtcdClient: def __init__( self, - username, - password, + username: str, + password: str, client_url: str, ): self.client_url = client_url diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 6e54724..27184fc 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 Optional import yaml from pytest_operator.plugin import OpsTest @@ -22,13 +23,17 @@ def put_key( model: str, unit: str, endpoints: str, - user: str, - password: str, key: str, value: str, + user: Optional[str] = None, + password: Optional[str] = None, ) -> str: """Write data to etcd using `etcdctl` via `juju ssh`.""" - etcd_command = f"{SNAP_NAME}.etcdctl put {key} {value} --endpoints={endpoints} --user={user} --password={password}" + 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] @@ -38,12 +43,17 @@ def get_key( model: str, unit: str, endpoints: str, - user: str, - password: str, key: str, + user: Optional[str] = None, + password: Optional[str] = None, ) -> str: """Read data from etcd using `etcdctl` via `juju ssh`.""" - etcd_command = f"{SNAP_NAME}.etcdctl get {key} --endpoints={endpoints} --user={user} --password={password}" + 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] diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 249bd49..edd18a4 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -67,3 +67,43 @@ async def test_build_and_deploy(ops_test: OpsTest) -> None: 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. + + Test reading and writing data without providing credentials. + Test updating the password of the internal admin user and make sure it can be used. + """ + 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" + new_password = "my_new_pwd" + + # 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" + + # run set-password action + action = await ops_test.model.units.get(leader_unit).run_action( + action_name="set-password", password=new_password + ) + result = await action.wait() + assert result.results.get(f"{INTERNAL_USER}-password") == new_password + + # run get-password action + updated_password = await get_user_password(ops_test, user=INTERNAL_USER, unit=leader_unit) + assert updated_password == new_password + + # use updated password to read data + assert ( + get_key( + model, leader_unit, endpoints, user=INTERNAL_USER, password=new_password, key=test_key + ) + == test_value + ) From 7404ac71422759d4715f7cb0daff10e17bf297b8 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Thu, 5 Dec 2024 07:47:42 +0000 Subject: [PATCH 41/50] only enable authentication if not already done --- src/core/models.py | 5 +++++ src/events/etcd.py | 3 ++- tests/unit/test_charm.py | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/core/models.py b/src/core/models.py index 9f0369a..1dd53a0 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -127,3 +127,8 @@ def internal_user_credentials(self) -> dict[str, str]: 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/events/etcd.py b/src/events/etcd.py index 735d416..52a5bde 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -79,9 +79,10 @@ def _on_start(self, event: ops.StartEvent) -> None: self.charm.workload.start() - if self.charm.unit.is_leader(): + 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) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index df2ef6d..6d61ddd 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -82,6 +82,7 @@ def test_start(): ): 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 ( From 9886c5f00c14e4de5a2ec823773edc22e8efae6f Mon Sep 17 00:00:00 2001 From: reneradoi Date: Fri, 6 Dec 2024 09:19:45 +0000 Subject: [PATCH 42/50] address PR review feedback --- src/common/client.py | 15 +++++++-------- src/common/exceptions.py | 6 ------ src/core/models.py | 6 ------ src/events/actions.py | 1 + tests/unit/test_actions.py | 7 +++++++ 5 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/common/client.py b/src/common/client.py index 994975c..2ce6a55 100644 --- a/src/common/client.py +++ b/src/common/client.py @@ -7,7 +7,6 @@ import json import logging import subprocess -from typing import Optional from common.exceptions import EtcdAuthNotEnabledError, EtcdUserManagementError from literals import INTERNAL_USER, SNAP_NAME @@ -88,16 +87,16 @@ def _run_etcdctl( self, command: str, endpoints: str, - subcommand: Optional[str] = None, + subcommand: str = 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: Optional[str] = None, - auth_password: Optional[str] = None, - user: Optional[str] = None, - user_password: Optional[str] = None, - output_format: Optional[str] = "simple", - use_input: Optional[str] = None, + auth_username: str = None, + auth_password: str = None, + user: str = None, + user_password: str = None, + output_format: str = "simple", + use_input: str = None, ) -> str | None: """Execute `etcdctl` command via subprocess. diff --git a/src/common/exceptions.py b/src/common/exceptions.py index a1d81bb..8fd165b 100644 --- a/src/common/exceptions.py +++ b/src/common/exceptions.py @@ -8,16 +8,10 @@ class RaftLeaderNotFoundError(Exception): """Custom Exception if there is no current Raft leader.""" - pass - class EtcdUserManagementError(Exception): """Custom Exception if user could not be added or updated in etcd cluster.""" - pass - class EtcdAuthNotEnabledError(Exception): """Custom Exception if authentication could not be enabled in the etcd cluster.""" - - pass diff --git a/src/core/models.py b/src/core/models.py index 1dd53a0..2c7ed91 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -5,7 +5,6 @@ """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 @@ -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: diff --git a/src/events/actions.py b/src/events/actions.py index b35a561..f0616ba 100644 --- a/src/events/actions.py +++ b/src/events/actions.py @@ -40,6 +40,7 @@ def _on_get_password(self, event: ActionEvent) -> None: event.fail("User credentials not created yet.") return + # todo: add the TLS CA chain here once TLS is implemented event.set_results( { "username": username, diff --git a/tests/unit/test_actions.py b/tests/unit/test_actions.py index b18af42..bfeacce 100644 --- a/tests/unit/test_actions.py +++ b/tests/unit/test_actions.py @@ -31,6 +31,13 @@ def test_get_password(): assert ctx.action_results.get("password") assert ctx.action_results.get("ca-chain") + with raises(ActionFailed) as error: + ctx.run( + ctx.on.action("get-password", params={"username": "my_user"}), + state_in, + ) + assert error.value.message == f"Action only allowed for user {INTERNAL_USER}." + def test_set_password(): ctx = testing.Context(EtcdOperatorCharm) From 124d5fd39c034feabf7c8764f55f30a98899f544 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Fri, 6 Dec 2024 11:20:29 +0000 Subject: [PATCH 43/50] fix replacing `Optional` with `str | None` --- src/common/client.py | 14 +++++++------- tests/integration/helpers.py | 9 ++++----- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/common/client.py b/src/common/client.py index 2ce6a55..e6b51ec 100644 --- a/src/common/client.py +++ b/src/common/client.py @@ -87,16 +87,16 @@ def _run_etcdctl( self, command: str, endpoints: str, - subcommand: str = None, + 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, - auth_password: str = None, - user: str = None, - user_password: str = None, - output_format: str = "simple", - use_input: str = None, + auth_username: str | None = None, + auth_password: str | None = None, + user: str | None = None, + user_password: str | None = None, + output_format: str | None = "simple", + use_input: str | None = None, ) -> str | None: """Execute `etcdctl` command via subprocess. diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 27184fc..a3a152f 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -6,7 +6,6 @@ import logging import subprocess from pathlib import Path -from typing import Optional import yaml from pytest_operator.plugin import OpsTest @@ -25,8 +24,8 @@ def put_key( endpoints: str, key: str, value: str, - user: Optional[str] = None, - password: Optional[str] = None, + 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}" @@ -44,8 +43,8 @@ def get_key( unit: str, endpoints: str, key: str, - user: Optional[str] = None, - password: Optional[str] = None, + 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}" From 2c5ebe6fa13fcbcb46c4dc77835084bc68dd6833 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Fri, 6 Dec 2024 11:54:01 +0000 Subject: [PATCH 44/50] fix type hint --- src/common/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/client.py b/src/common/client.py index e6b51ec..fdac114 100644 --- a/src/common/client.py +++ b/src/common/client.py @@ -95,7 +95,7 @@ def _run_etcdctl( auth_password: str | None = None, user: str | None = None, user_password: str | None = None, - output_format: str | None = "simple", + output_format: str = "simple", use_input: str | None = None, ) -> str | None: """Execute `etcdctl` command via subprocess. From ec03f83ac6654079a6ae63707144af950560b58c Mon Sep 17 00:00:00 2001 From: reneradoi Date: Fri, 6 Dec 2024 14:40:46 +0000 Subject: [PATCH 45/50] remove implementation for `get-password` and `set-password` user actions --- actions.yaml | 21 --------- src/charm.py | 2 - src/events/actions.py | 75 ------------------------------- tests/integration/helpers.py | 13 ------ tests/integration/test_charm.py | 21 --------- tests/unit/test_actions.py | 78 --------------------------------- 6 files changed, 210 deletions(-) delete mode 100644 actions.yaml delete mode 100644 src/events/actions.py delete mode 100644 tests/unit/test_actions.py diff --git a/actions.yaml b/actions.yaml deleted file mode 100644 index b1295ad..0000000 --- a/actions.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. - -set-password: - description: Change the admin user's password, which is used by charm. It is for internal charm users and SHOULD NOT be used by applications. - params: - username: - type: string - description: The username, the default value 'root'. Possible values - 'root'. - default: root - password: - type: string - description: The password will be auto-generated if this option is not set. - -get-password: - description: Fetch the admin user's password and CA chain, which is used by charm. It is for internal charm users and SHOULD NOT be used by applications. - params: - username: - type: string - description: The username, the default value 'root'. Possible values - 'root'. - default: root \ No newline at end of file diff --git a/src/charm.py b/src/charm.py index 0e36abf..3c463b2 100755 --- a/src/charm.py +++ b/src/charm.py @@ -10,7 +10,6 @@ from ops import StatusBase from core.cluster import ClusterState -from events.actions import ActionEvents from events.etcd import EtcdEvents from literals import SUBSTRATE, DebugLevel, Status from managers.cluster import ClusterManager @@ -36,7 +35,6 @@ def __init__(self, *args): # --- EVENT HANDLERS --- self.etcd_events = EtcdEvents(self) - self.action_events = ActionEvents(self) def set_status(self, key: Status) -> None: """Set charm status.""" diff --git a/src/events/actions.py b/src/events/actions.py deleted file mode 100644 index f0616ba..0000000 --- a/src/events/actions.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Event handlers for Juju Actions.""" - -import logging -from typing import TYPE_CHECKING - -from ops.charm import ActionEvent -from ops.framework import Object - -from common.exceptions import EtcdUserManagementError -from literals import INTERNAL_USER - -if TYPE_CHECKING: - from charm import EtcdOperatorCharm - -logger = logging.getLogger(__name__) - - -class ActionEvents(Object): - """Handle all events for user-related Juju Actions.""" - - def __init__(self, charm: "EtcdOperatorCharm"): - super().__init__(charm, key="action_events") - self.charm = charm - - self.framework.observe(self.charm.on.set_password_action, self._on_set_password) - self.framework.observe(self.charm.on.get_password_action, self._on_get_password) - - def _on_get_password(self, event: ActionEvent) -> None: - """Return the password and certificate chain for the internal admin user.""" - username = event.params.get("username") - if username != INTERNAL_USER: - event.fail(f"Action only allowed for user {INTERNAL_USER}.") - return - - if not self.charm.state.cluster.internal_user_credentials: - event.fail("User credentials not created yet.") - return - - # todo: add the TLS CA chain here once TLS is implemented - event.set_results( - { - "username": username, - "password": self.charm.state.cluster.internal_user_credentials[INTERNAL_USER], - "ca-chain": "...", - } - ) - - def _on_set_password(self, event: ActionEvent) -> None: - """Handle the `set-password` action for the internal admin user. - - If no password is provided, generate one. - """ - username = event.params.get("username") - if username != INTERNAL_USER: - event.fail(f"Action only allowed for user {INTERNAL_USER}.") - return - - if not self.charm.unit.is_leader(): - event.fail("Action can only be run on the leader unit.") - return - - new_password = event.params.get("password") or self.charm.workload.generate_password() - - try: - self.charm.cluster_manager.update_credentials(username=username, password=new_password) - self.charm.state.cluster.update({f"{INTERNAL_USER}-password": new_password}) - except EtcdUserManagementError as e: - logger.error(e) - event.fail(e) - - event.set_results({f"{username}-password": new_password}) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index a3a152f..e5d672a 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -83,16 +83,3 @@ 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_user_password(ops_test: OpsTest, user: str, unit: str) -> str: - """Use the action to retrieve the password for a user. - - Return: - String with the password stored on the peer relation databag. - """ - action = await ops_test.model.units.get(unit).run_action( - action_name="get-password", params={"username": user} - ) - credentials = await action.wait() - return credentials.results["password"] diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index edd18a4..0333869 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -15,7 +15,6 @@ get_cluster_members, get_juju_leader_unit_name, get_key, - get_user_password, put_key, ) @@ -83,27 +82,7 @@ async def test_authentication(ops_test: OpsTest) -> None: leader_unit = await get_juju_leader_unit_name(ops_test, APP_NAME) test_key = "test_key" test_value = "42" - new_password = "my_new_pwd" # 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" - - # run set-password action - action = await ops_test.model.units.get(leader_unit).run_action( - action_name="set-password", password=new_password - ) - result = await action.wait() - assert result.results.get(f"{INTERNAL_USER}-password") == new_password - - # run get-password action - updated_password = await get_user_password(ops_test, user=INTERNAL_USER, unit=leader_unit) - assert updated_password == new_password - - # use updated password to read data - assert ( - get_key( - model, leader_unit, endpoints, user=INTERNAL_USER, password=new_password, key=test_key - ) - == test_value - ) diff --git a/tests/unit/test_actions.py b/tests/unit/test_actions.py deleted file mode 100644 index bfeacce..0000000 --- a/tests/unit/test_actions.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. - -from pathlib import Path -from subprocess import CompletedProcess -from unittest.mock import patch - -import yaml -from ops import testing -from ops.testing import ActionFailed -from pytest import raises - -from charm import EtcdOperatorCharm -from literals import INTERNAL_USER, PEER_RELATION - -METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) -APP_NAME = METADATA["name"] - - -def test_get_password(): - ctx = testing.Context(EtcdOperatorCharm) - relation = testing.PeerRelation(id=1, endpoint=PEER_RELATION) - - # make sure admin credentials are created initially - state_in = testing.State(relations={relation}, leader=True) - state_out = ctx.run(ctx.on.leader_elected(), state_in) - - ctx.run(ctx.on.action("get-password", params={"username": f"{INTERNAL_USER}"}), state_out) - assert ctx.action_results.get("username") == INTERNAL_USER - assert ctx.action_results.get("password") - assert ctx.action_results.get("ca-chain") - - with raises(ActionFailed) as error: - ctx.run( - ctx.on.action("get-password", params={"username": "my_user"}), - state_in, - ) - assert error.value.message == f"Action only allowed for user {INTERNAL_USER}." - - -def test_set_password(): - ctx = testing.Context(EtcdOperatorCharm) - relation = testing.PeerRelation(id=1, endpoint=PEER_RELATION) - password = "test_pwd" - - # this action is not allowed on non-leader units - state_in = testing.State(relations={relation}, leader=False) - with raises(ActionFailed) as error: - ctx.run( - ctx.on.action( - "set-password", params={"username": INTERNAL_USER, "password": password} - ), - state_in, - ) - assert error.value.message == "Action can only be run on the leader unit." - - # make sure a password cannot set for any other than the admin user - state_in = testing.State(relations={relation}, leader=True) - with raises(ActionFailed) as error: - ctx.run( - ctx.on.action("set-password", params={"username": "my_user", "password": password}), - state_in, - ) - assert error.value.message == f"Action only allowed for user {INTERNAL_USER}." - - # update the admin user's password - state_in = testing.State(relations={relation}, leader=True) - with patch( - "subprocess.run", return_value=CompletedProcess(returncode=0, args=[], stdout="OK") - ): - ctx.run( - ctx.on.action( - "set-password", params={"username": INTERNAL_USER, "password": password} - ), - state_in, - ) - assert ctx.action_results.get(f"{INTERNAL_USER}-password") == password From 8ee63e5db1800f2a52837f3d15611bfa70e04394 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Mon, 9 Dec 2024 11:19:26 +0000 Subject: [PATCH 46/50] WIP: configure admin's user password via juju user secret --- config.yaml | 11 +++++++++ pyproject.toml | 2 +- src/common/client.py | 2 +- src/common/secrets.py | 22 +++++++++++++++++ src/events/etcd.py | 43 +++++++++++++++++++++++++++++++-- src/literals.py | 1 + tests/integration/helpers.py | 19 ++++++++++++++- tests/integration/test_charm.py | 3 ++- 8 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 config.yaml create mode 100644 src/common/secrets.py diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..ed0af3b --- /dev/null +++ b/config.yaml @@ -0,0 +1,11 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +options: + admin-password: + type: secret + description: | + Configure the admin user'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 key: `admin-password`. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index b4b7a44..ece49a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,7 @@ lint.extend-ignore = [ "D409", "D413", ] -lint.ignore = ["E501", "D107", "C901"] +lint.ignore = ["E501", "D107"] extend-exclude = ["__pycache__", "*.egg_info"] lint.per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]} diff --git a/src/common/client.py b/src/common/client.py index fdac114..ff49fb0 100644 --- a/src/common/client.py +++ b/src/common/client.py @@ -83,7 +83,7 @@ def enable_auth(self) -> None: else: raise EtcdAuthNotEnabledError("Failed to enable authentication in etcd.") - def _run_etcdctl( + def _run_etcdctl( # noqa: C901 self, command: str, endpoints: str, 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/events/etcd.py b/src/events/etcd.py index 52a5bde..6126b33 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -16,13 +16,15 @@ RelationDepartedEvent, RelationJoinedEvent, ) +from ops.model import ModelError, SecretNotFoundError from common.exceptions import ( EtcdAuthNotEnabledError, EtcdUserManagementError, RaftLeaderNotFoundError, ) -from literals import INTERNAL_USER, PEER_RELATION, Status +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 @@ -56,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.""" @@ -95,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.""" @@ -137,3 +144,35 @@ 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_PASSWORD_CONFIG + ): + # 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} has 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 f23ee41..385ccfc 100644 --- a/src/literals.py +++ b/src/literals.py @@ -22,6 +22,7 @@ PEER_PORT = 2380 INTERNAL_USER = "root" +INTERNAL_USER_PASSWORD_CONFIG = "admin-password" SECRETS_APP = ["root-password"] DebugLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"] diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index e5d672a..352f88d 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -6,11 +6,12 @@ import logging import subprocess from pathlib import Path +from typing import Dict import yaml from pytest_operator.plugin import OpsTest -from literals import CLIENT_PORT, SNAP_NAME +from literals import CLIENT_PORT, PEER_RELATION, SNAP_NAME logger = logging.getLogger(__name__) @@ -83,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 0333869..1b4c994 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -15,6 +15,7 @@ get_cluster_members, get_juju_leader_unit_name, get_key, + get_secret_by_label, put_key, ) @@ -47,7 +48,7 @@ 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 - password = await get_user_password(ops_test, user=INTERNAL_USER, unit=leader_unit) + password = await get_secret_by_label(ops_test, label=f"{APP_NAME}:app:") # get_user_password(ops_test, user=INTERNAL_USER, unit=leader_unit) test_key = "test_key" test_value = "42" assert ( From ea8b4c9fd39a8577be4d3682fa8d68023369f94a Mon Sep 17 00:00:00 2001 From: reneradoi Date: Mon, 9 Dec 2024 13:21:54 +0000 Subject: [PATCH 47/50] add test coverage --- tests/integration/helpers.py | 2 +- tests/integration/test_charm.py | 59 +++++++++++++++++++++++++++++---- tests/unit/test_charm.py | 21 ++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 352f88d..a62ee0f 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -11,7 +11,7 @@ import yaml from pytest_operator.plugin import OpsTest -from literals import CLIENT_PORT, PEER_RELATION, SNAP_NAME +from literals import CLIENT_PORT, SNAP_NAME logger = logging.getLogger(__name__) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 1b4c994..ead8e5f 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -7,7 +7,7 @@ import pytest from pytest_operator.plugin import OpsTest -from literals import INTERNAL_USER +from literals import INTERNAL_USER, PEER_RELATION from .helpers import ( APP_NAME, @@ -48,7 +48,9 @@ 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 - password = await get_secret_by_label(ops_test, label=f"{APP_NAME}:app:") # get_user_password(ops_test, user=INTERNAL_USER, unit=leader_unit) + 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 ( @@ -73,11 +75,7 @@ async def test_build_and_deploy(ops_test: OpsTest) -> None: @pytest.mark.group(1) @pytest.mark.abort_on_fail async def test_authentication(ops_test: OpsTest) -> None: - """Assert authentication is enabled by default. - - Test reading and writing data without providing credentials. - Test updating the password of the internal admin user and make sure it can be used. - """ + """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) @@ -87,3 +85,50 @@ async def test_authentication(ops_test: OpsTest) -> None: # 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"admin-password={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({"admin-password": secret_id}) + + await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active", timeout=1000) + + # perform read/write operation with the updated password + assert ( + get_key( + model, leader_unit, endpoints, user=INTERNAL_USER, password=new_password, key=test_key + ) + == test_value + ) + + assert ( + put_key( + model, + leader_unit, + endpoints, + user=INTERNAL_USER, + password=new_password, + key=test_key, + value="43", + ) + == "OK" + ) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 6d61ddd..5de30b7 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -138,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 = "admin-password" + 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={secret_key: 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 From 0af4d26694c08e6ec0ecb838d6a53f883181f4c9 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Mon, 9 Dec 2024 13:24:51 +0000 Subject: [PATCH 48/50] fix linting --- tests/unit/test_charm.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 5de30b7..7eb7927 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -149,10 +149,7 @@ def test_config_changed(): ctx = testing.Context(EtcdOperatorCharm) state_in = testing.State( - secrets=[secret], - config={secret_key: secret.id}, - relations={relation}, - leader=True + secrets=[secret], config={secret_key: secret.id}, relations={relation}, leader=True ) with patch("subprocess.run"): From fa8cee1e3c6966e77c35debc1165962e89f2f0fc Mon Sep 17 00:00:00 2001 From: reneradoi Date: Mon, 9 Dec 2024 17:10:59 +0000 Subject: [PATCH 49/50] add test for password to be still valid if config option was removed --- tests/integration/test_charm.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index ead8e5f..453604e 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -109,10 +109,9 @@ async def test_update_admin_password(ops_test: OpsTest) -> None: # update the application config to include the secret await ops_test.model.applications[APP_NAME].set_config({"admin-password": secret_id}) - await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active", timeout=1000) - # perform read/write operation with the updated password + # perform read operation with the updated password assert ( get_key( model, leader_unit, endpoints, user=INTERNAL_USER, password=new_password, key=test_key @@ -120,15 +119,14 @@ async def test_update_admin_password(ops_test: OpsTest) -> None: == test_value ) + # update the config again and remove the option `admin-password` + await ops_test.model.applications[APP_NAME].reset_config(["admin-password"]) + 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 ( - put_key( - model, - leader_unit, - endpoints, - user=INTERNAL_USER, - password=new_password, - key=test_key, - value="43", + get_key( + model, leader_unit, endpoints, user=INTERNAL_USER, password=new_password, key=test_key ) - == "OK" + == test_value ) From 66bee61fb9f9e53cabeb8a5c31b3e45784cc263c Mon Sep 17 00:00:00 2001 From: reneradoi Date: Tue, 10 Dec 2024 11:00:13 +0000 Subject: [PATCH 50/50] rename config option to `system-users`, allow for storing multiple internal users in the referenced secret --- config.yaml | 10 +++++----- src/events/etcd.py | 4 ++-- src/literals.py | 2 +- tests/integration/test_charm.py | 10 ++++++---- tests/unit/test_charm.py | 9 ++++++--- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/config.yaml b/config.yaml index ed0af3b..410c612 100644 --- a/config.yaml +++ b/config.yaml @@ -2,10 +2,10 @@ # See LICENSE file for licensing details. options: - admin-password: + system-users: type: secret description: | - Configure the admin user'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 key: `admin-password`. \ No newline at end of file + 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/events/etcd.py b/src/events/etcd.py index 6126b33..ed5fe5e 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -158,13 +158,13 @@ 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_PASSWORD_CONFIG + 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} has changed.") + logger.debug(f"{INTERNAL_USER_PASSWORD_CONFIG} have changed.") try: self.charm.cluster_manager.update_credentials( username=INTERNAL_USER, password=new_password diff --git a/src/literals.py b/src/literals.py index 385ccfc..270a843 100644 --- a/src/literals.py +++ b/src/literals.py @@ -22,7 +22,7 @@ PEER_PORT = 2380 INTERNAL_USER = "root" -INTERNAL_USER_PASSWORD_CONFIG = "admin-password" +INTERNAL_USER_PASSWORD_CONFIG = "system-users" SECRETS_APP = ["root-password"] DebugLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"] diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 453604e..4f5db4c 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -7,7 +7,7 @@ import pytest from pytest_operator.plugin import OpsTest -from literals import INTERNAL_USER, PEER_RELATION +from literals import INTERNAL_USER, INTERNAL_USER_PASSWORD_CONFIG, PEER_RELATION from .helpers import ( APP_NAME, @@ -103,12 +103,14 @@ async def test_update_admin_password(ops_test: OpsTest) -> None: new_password = "some-password" secret_id = await ops_test.model.add_secret( - name=secret_name, data_args=[f"admin-password={new_password}"] + 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({"admin-password": secret_id}) + 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 @@ -120,7 +122,7 @@ async def test_update_admin_password(ops_test: OpsTest) -> None: ) # update the config again and remove the option `admin-password` - await ops_test.model.applications[APP_NAME].reset_config(["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 diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 7eb7927..76be2d6 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -11,7 +11,7 @@ from ops import testing from charm import EtcdOperatorCharm -from literals import CLIENT_PORT, INTERNAL_USER, 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"] @@ -141,7 +141,7 @@ def test_get_leader(): def test_config_changed(): - secret_key = "admin-password" + secret_key = "root" secret_value = "123" secret_content = {secret_key: secret_value} secret = ops.testing.Secret(tracked_content=secret_content, remote_grants=APP_NAME) @@ -149,7 +149,10 @@ def test_config_changed(): ctx = testing.Context(EtcdOperatorCharm) state_in = testing.State( - secrets=[secret], config={secret_key: secret.id}, relations={relation}, leader=True + secrets=[secret], + config={INTERNAL_USER_PASSWORD_CONFIG: secret.id}, + relations={relation}, + leader=True, ) with patch("subprocess.run"):