From 6ae2522add36632ae8896eed54f915051d6f7ade Mon Sep 17 00:00:00 2001 From: marcoppenheimer <51744472+marcoppenheimer@users.noreply.github.com> Date: Thu, 15 Aug 2024 14:26:01 +0100 Subject: [PATCH] [DPE-4703] - feat: external k8s access via NodePort (#110) --- config.yaml | 4 + lib/charms/operator_libs_linux/v0/sysctl.py | 288 +++++ lib/charms/operator_libs_linux/v1/snap.py | 1065 +++++++++++++++++++ poetry.lock | 537 ++++++---- pyproject.toml | 8 +- requirements.txt | 13 +- src/charm.py | 1 + src/core/cluster.py | 68 +- src/core/models.py | 68 +- src/core/structured_config.py | 15 +- src/core/workload.py | 29 +- src/events/balancer.py | 86 +- src/events/broker.py | 249 +++-- src/events/oauth.py | 3 +- src/events/peer_cluster.py | 2 +- src/events/tls.py | 108 +- src/events/upgrade.py | 29 +- src/events/zookeeper.py | 6 +- src/health.py | 194 ++++ src/literals.py | 53 +- src/managers/config.py | 135 ++- src/managers/k8s.py | 233 ++++ src/managers/tls.py | 88 +- src/workload.py | 118 +- tests/integration/ha/test_ha.py | 10 +- tests/integration/helpers.py | 94 +- tests/integration/test_balancer.py | 2 +- tests/integration/test_charm.py | 30 +- tests/integration/test_password_rotation.py | 2 + tests/integration/test_provider.py | 38 +- tests/integration/test_scaling.py | 5 +- tests/integration/test_tls.py | 15 +- tests/integration/test_upgrade.py | 5 +- tests/unit/conftest.py | 11 + tests/unit/test_charm.py | 5 +- tests/unit/test_charm_balancer.py | 43 +- tests/unit/test_config.py | 16 +- tests/unit/test_provider.py | 1 + tests/unit/test_tls.py | 63 +- tests/unit/test_upgrade.py | 9 +- tox.ini | 4 +- 41 files changed, 3079 insertions(+), 674 deletions(-) create mode 100644 lib/charms/operator_libs_linux/v0/sysctl.py create mode 100644 lib/charms/operator_libs_linux/v1/snap.py create mode 100644 src/health.py create mode 100644 src/managers/k8s.py diff --git a/config.yaml b/config.yaml index fb02269f..a13fff71 100644 --- a/config.yaml +++ b/config.yaml @@ -108,3 +108,7 @@ options: description: The maximum percentage of the total cpu, disk and network capacity that is allowed to be used on a broker. For example, a value of `0.8` ensures that no broker should have >80% utilization type: float default: 0.8 + expose-external: + description: "String to determine how to expose the Kafka cluster externally from the Kubernetes cluster. Possible values: 'nodeport', 'none'" + type: string + default: "nodeport" diff --git a/lib/charms/operator_libs_linux/v0/sysctl.py b/lib/charms/operator_libs_linux/v0/sysctl.py new file mode 100644 index 00000000..8245835e --- /dev/null +++ b/lib/charms/operator_libs_linux/v0/sysctl.py @@ -0,0 +1,288 @@ +# 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. + +"""Handler for the sysctl config. + +This library allows your charm to create and configure sysctl options to the machine. + +Validation and merge capabilities are added, for situations where more than one application +are setting values. The following files can be created: + +- /etc/sysctl.d/90-juju- + Requirements from one application requesting to configure the values. + +- /etc/sysctl.d/95-juju-sysctl.conf + Merged file resulting from all other `90-juju-*` application files. + + +A charm using the sysctl lib will need a data structure like the following: +``` +{ +"vm.swappiness": "1", +"vm.max_map_count": "262144", +"vm.dirty_ratio": "80", +"vm.dirty_background_ratio": "5", +"net.ipv4.tcp_max_syn_backlog": "4096", +} +``` + +Now, it can use that template within the charm, or just declare the values directly: + +```python +from charms.operator_libs_linux.v0 import sysctl + +class MyCharm(CharmBase): + + def __init__(self, *args): + ... + self.sysctl = sysctl.Config(self.meta.name) + + self.framework.observe(self.on.install, self._on_install) + self.framework.observe(self.on.remove, self._on_remove) + + def _on_install(self, _): + # Altenatively, read the values from a template + sysctl_data = {"net.ipv4.tcp_max_syn_backlog": "4096"}} + + try: + self.sysctl.configure(config=sysctl_data) + except (sysctl.ApplyError, sysctl.ValidationError) as e: + logger.error(f"Error setting values on sysctl: {e.message}") + self.unit.status = BlockedStatus("Sysctl config not possible") + except sysctl.CommandError: + logger.error("Error on sysctl") + + def _on_remove(self, _): + self.sysctl.remove() +``` +""" + +import logging +import re +from pathlib import Path +from subprocess import STDOUT, CalledProcessError, check_output +from typing import Dict, List + +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "17a6cd4d80104d15b10f9c2420ab3266" + +# 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 = 4 + +CHARM_FILENAME_PREFIX = "90-juju-" +SYSCTL_DIRECTORY = Path("/etc/sysctl.d") +SYSCTL_FILENAME = SYSCTL_DIRECTORY / "95-juju-sysctl.conf" +SYSCTL_HEADER = f"""# This config file was produced by sysctl lib v{LIBAPI}.{LIBPATCH} +# +# This file represents the output of the sysctl lib, which can combine multiple +# configurations into a single file like. +""" + + +class Error(Exception): + """Base class of most errors raised by this library.""" + + @property + def message(self): + """Return the message passed as an argument.""" + return self.args[0] + + +class CommandError(Error): + """Raised when there's an error running sysctl command.""" + + +class ApplyError(Error): + """Raised when there's an error applying values in sysctl.""" + + +class ValidationError(Error): + """Exception representing value validation error.""" + + +class Config(Dict): + """Represents the state of the config that a charm wants to enforce.""" + + _apply_re = re.compile(r"sysctl: permission denied on key \"([a-z_\.]+)\", ignoring$") + + def __init__(self, name: str) -> None: + self.name = name + self._data = self._load_data() + + def __contains__(self, key: str) -> bool: + """Check if key is in config.""" + return key in self._data + + def __len__(self): + """Get size of config.""" + return len(self._data) + + def __iter__(self): + """Iterate over config.""" + return iter(self._data) + + def __getitem__(self, key: str) -> str: + """Get value for key form config.""" + return self._data[key] + + @property + def charm_filepath(self) -> Path: + """Name for resulting charm config file.""" + return SYSCTL_DIRECTORY / f"{CHARM_FILENAME_PREFIX}{self.name}" + + def configure(self, config: Dict[str, str]) -> None: + """Configure sysctl options with a desired set of params. + + Args: + config: dictionary with keys to configure: + ``` + {"vm.swappiness": "10", ...} + ``` + """ + self._parse_config(config) + + # NOTE: case where own charm calls configure() more than once. + if self.charm_filepath.exists(): + self._merge(add_own_charm=False) + + conflict = self._validate() + if conflict: + raise ValidationError(f"Validation error for keys: {conflict}") + + snapshot = self._create_snapshot() + logger.debug("Created snapshot for keys: %s", snapshot) + try: + self._apply() + except ApplyError: + self._restore_snapshot(snapshot) + raise + + self._create_charm_file() + self._merge() + + def remove(self) -> None: + """Remove config for charm. + + The removal process won't apply any sysctl configuration. It will only merge files from + remaining charms. + """ + self.charm_filepath.unlink(missing_ok=True) + logger.info("Charm config file %s was removed", self.charm_filepath) + self._merge() + + def _validate(self) -> List[str]: + """Validate the desired config params against merged ones.""" + common_keys = set(self._data.keys()) & set(self._desired_config.keys()) + conflict_keys = [] + for key in common_keys: + if self._data[key] != self._desired_config[key]: + logger.warning( + "Values for key '%s' are different: %s != %s", + key, + self._data[key], + self._desired_config[key], + ) + conflict_keys.append(key) + + return conflict_keys + + def _create_charm_file(self) -> None: + """Write the charm file.""" + with open(self.charm_filepath, "w") as f: + f.write(f"# {self.name}\n") + for key, value in self._desired_config.items(): + f.write(f"{key}={value}\n") + + def _merge(self, add_own_charm=True) -> None: + """Create the merged sysctl file. + + Args: + add_own_charm : bool, if false it will skip the charm file from the merge. + """ + # get all files that start by 90-juju- + data = [SYSCTL_HEADER] + paths = set(SYSCTL_DIRECTORY.glob(f"{CHARM_FILENAME_PREFIX}*")) + if not add_own_charm: + paths.discard(self.charm_filepath) + + for path in paths: + with open(path, "r") as f: + data += f.readlines() + with open(SYSCTL_FILENAME, "w") as f: + f.writelines(data) + + # Reload data with newly created file. + self._data = self._load_data() + + def _apply(self) -> None: + """Apply values to machine.""" + cmd = [f"{key}={value}" for key, value in self._desired_config.items()] + result = self._sysctl(cmd) + failed_values = [ + self._apply_re.match(line) for line in result if self._apply_re.match(line) + ] + logger.debug("Failed values: %s", failed_values) + + if failed_values: + msg = f"Unable to set params: {[f.group(1) for f in failed_values]}" + logger.error(msg) + raise ApplyError(msg) + + def _create_snapshot(self) -> Dict[str, str]: + """Create a snapshot of config options that are going to be set.""" + cmd = ["-n"] + list(self._desired_config.keys()) + values = self._sysctl(cmd) + return dict(zip(list(self._desired_config.keys()), values)) + + def _restore_snapshot(self, snapshot: Dict[str, str]) -> None: + """Restore a snapshot to the machine.""" + values = [f"{key}={value}" for key, value in snapshot.items()] + self._sysctl(values) + + def _sysctl(self, cmd: List[str]) -> List[str]: + """Execute a sysctl command.""" + cmd = ["sysctl"] + cmd + logger.debug("Executing sysctl command: %s", cmd) + try: + return check_output(cmd, stderr=STDOUT, universal_newlines=True).splitlines() + except CalledProcessError as e: + msg = f"Error executing '{cmd}': {e.stdout}" + logger.error(msg) + raise CommandError(msg) + + def _parse_config(self, config: Dict[str, str]) -> None: + """Parse a config passed to the lib.""" + self._desired_config = {k: str(v) for k, v in config.items()} + + def _load_data(self) -> Dict[str, str]: + """Get merged config.""" + config = {} + if not SYSCTL_FILENAME.exists(): + return config + + with open(SYSCTL_FILENAME, "r") as f: + for line in f: + if line.startswith(("#", ";")) or not line.strip() or "=" not in line: + continue + + key, _, value = line.partition("=") + config[key.strip()] = value.strip() + + return config diff --git a/lib/charms/operator_libs_linux/v1/snap.py b/lib/charms/operator_libs_linux/v1/snap.py new file mode 100644 index 00000000..71cdee39 --- /dev/null +++ b/lib/charms/operator_libs_linux/v1/snap.py @@ -0,0 +1,1065 @@ +# 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 = 1 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 12 + + +# 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" or "strict" + """ + + def __init__( + self, + name, + state: SnapState, + channel: str, + revision: int, + 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 [] + _cmd = ["snap", command, self._name, *optargs] + try: + return subprocess.check_output(_cmd, universal_newlines=True) + except CalledProcessError as e: + raise SnapError( + "Snap: {!r}; command {!r} failed with output = {!r}".format( + self._name, _cmd, 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] + + _cmd = ["snap", *command, *services] + + try: + return subprocess.run(_cmd, universal_newlines=True, check=True, capture_output=True) + except CalledProcessError as e: + raise SnapError("Could not {} for snap [{}]: {}".format(_cmd, self._name, e.stderr)) + + def get(self, key) -> str: + """Fetch a snap configuration value. + + Args: + key: the key to retrieve + """ + return self._snap("get", [key]).strip() + + def set(self, config: Dict) -> str: + """Set a snap configuration value. + + Args: + config: a dictionary containing keys and values specifying the config to set. + """ + args = ['{}="{}"'.format(key, val) for key, val in config.items()] + + return self._snap("set", [*args]) + + 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] + + _cmd = ["snap", *command] + try: + subprocess.run(_cmd, universal_newlines=True, check=True, capture_output=True) + except CalledProcessError as e: + raise SnapError("Could not {} for snap [{}]: {}".format(_cmd, 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 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 show logs from. + (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[int] = 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 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[int] = None, + 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 + leave_cohort: leave the current cohort. + """ + args = [] + if channel: + args.append('--channel="{}"'.format(channel)) + + if revision: + args.append('--revision="{}"'.format(revision)) + + 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, + channel: Optional[str] = "", + cohort: Optional[str] = "", + revision: Optional[int] = 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 + 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 + """ + self._confinement = "classic" if classic or self._confinement == "classic" else "" + + 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. + self._install(channel, cohort, revision) + else: + # The snap is installed, but we are changing it (e.g., switching channels). + self._refresh(channel, cohort, revision) + + 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) -> int: + """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 = 5.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 5.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=int(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=int(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, + cohort: Optional[str] = "", + revision: Optional[int] = 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` + cohort: an (Optional) string specifying the snap cohort to use + revision: an (Optional) integer 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 type(snap_names) is str else snap_names + if not snap_names: + raise TypeError("Expected at least one snap to add, received zero!") + + if type(state) is str: + state = SnapState(state) + + return _wrap_snap_operations(snap_names, state, channel, classic, 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 type(snap_names) is 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, SnapState.Absent, "", False) + + +@_cache_init +def ensure( + snap_names: Union[str, List[str]], + state: str, + channel: Optional[str] = "", + classic: Optional[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` + 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, SnapState(state), channel, classic, cohort, revision) + else: + return remove(snap_names) + + +def _wrap_snap_operations( + snap_names: List[str], + state: SnapState, + channel: str, + classic: bool, + cohort: Optional[str] = "", + revision: Optional[int] = 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, 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, 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 + dangerous: whether --dangerous should be passed to install snaps without a signature + + Raises: + SnapError if there is a problem encountered + """ + _cmd = [ + "snap", + "install", + filename, + ] + if classic: + _cmd.append("--classic") + if dangerous: + _cmd.append("--dangerous") + try: + result = subprocess.check_output(_cmd, 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 + """ + _cmd = ["snap", "set", "system", "{}={}".format(config_item, value)] + try: + subprocess.check_call(_cmd, 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/poetry.lock b/poetry.lock index 56114423..a2e16766 100644 --- a/poetry.lock +++ b/poetry.lock @@ -42,22 +42,22 @@ test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] [[package]] name = "attrs" -version = "23.2.0" +version = "24.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.7" files = [ - {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, - {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, ] [package.extras] -cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] -dev = ["attrs[tests]", "pre-commit"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] -tests = ["attrs[tests-no-zope]", "zope-interface"] -tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] -tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] [[package]] name = "authlib" @@ -195,63 +195,78 @@ files = [ [[package]] name = "cffi" -version = "1.16.0" +version = "1.17.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" files = [ - {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, - {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, - {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, - {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, - {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, - {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, - {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, - {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, - {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, - {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, - {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, - {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, - {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, - {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, - {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, - {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, - {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, - {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, - {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, - {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, - {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, - {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, - {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, - {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, - {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, - {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, - {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, + {file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"}, + {file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"}, + {file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"}, + {file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"}, + {file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"}, + {file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"}, + {file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"}, + {file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"}, + {file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"}, + {file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"}, + {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"}, + {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"}, + {file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"}, + {file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"}, + {file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"}, + {file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"}, + {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"}, + {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"}, + {file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"}, + {file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"}, + {file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"}, + {file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"}, + {file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"}, + {file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"}, + {file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"}, + {file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"}, + {file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"}, + {file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"}, ] [package.dependencies] @@ -400,63 +415,83 @@ files = [ [[package]] name = "coverage" -version = "7.6.0" +version = "7.6.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd"}, - {file = "coverage-7.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c"}, - {file = "coverage-7.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463"}, - {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791"}, - {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c"}, - {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783"}, - {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6"}, - {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb"}, - {file = "coverage-7.6.0-cp310-cp310-win32.whl", hash = "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c"}, - {file = "coverage-7.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169"}, - {file = "coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933"}, - {file = "coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d"}, - {file = "coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94"}, - {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1"}, - {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac"}, - {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57"}, - {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d"}, - {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63"}, - {file = "coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713"}, - {file = "coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1"}, - {file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"}, - {file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"}, - {file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"}, - {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"}, - {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"}, - {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"}, - {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"}, - {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"}, - {file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"}, - {file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"}, - {file = "coverage-7.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b"}, - {file = "coverage-7.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d"}, - {file = "coverage-7.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca"}, - {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b"}, - {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44"}, - {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03"}, - {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6"}, - {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b"}, - {file = "coverage-7.6.0-cp38-cp38-win32.whl", hash = "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428"}, - {file = "coverage-7.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8"}, - {file = "coverage-7.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c"}, - {file = "coverage-7.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2"}, - {file = "coverage-7.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390"}, - {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b"}, - {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450"}, - {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6"}, - {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166"}, - {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd"}, - {file = "coverage-7.6.0-cp39-cp39-win32.whl", hash = "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2"}, - {file = "coverage-7.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca"}, - {file = "coverage-7.6.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6"}, - {file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, + {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, + {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, + {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, + {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, + {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, + {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, + {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, + {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, + {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, + {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, + {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, + {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, + {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, + {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, + {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, + {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, ] [package.dependencies] @@ -592,13 +627,13 @@ typing = ["typing-extensions (>=4.7.1)"] [[package]] name = "google-auth" -version = "2.32.0" +version = "2.33.0" description = "Google Authentication Library" optional = false python-versions = ">=3.7" files = [ - {file = "google_auth-2.32.0-py2.py3-none-any.whl", hash = "sha256:53326ea2ebec768070a94bee4e1b9194c9646ea0c2bd72422785bd0f9abfad7b"}, - {file = "google_auth-2.32.0.tar.gz", hash = "sha256:49315be72c55a6a37d62819e3573f6b416aca00721f7e3e31a008d928bf64022"}, + {file = "google_auth-2.33.0-py2.py3-none-any.whl", hash = "sha256:8eff47d0d4a34ab6265c50a106a3362de6a9975bb08998700e389f857e4d39df"}, + {file = "google_auth-2.33.0.tar.gz", hash = "sha256:d6a52342160d7290e334b4d47ba390767e4438ad0d45b7630774533e82655b95"}, ] [package.dependencies] @@ -919,13 +954,13 @@ adal = ["adal (>=1.0.2)"] [[package]] name = "lightkube" -version = "0.15.3" +version = "0.15.0" description = "Lightweight kubernetes client library" optional = false python-versions = "*" files = [ - {file = "lightkube-0.15.3-py3-none-any.whl", hash = "sha256:0f1c5e3b80f727df161965eff9cd6bc029c47e5a8569ab3e1acc429fd8c73db1"}, - {file = "lightkube-0.15.3.tar.gz", hash = "sha256:235b66409b67e06ce26bd5c7ae27e105bb6016ce0962d144e272a31155545e27"}, + {file = "lightkube-0.15.0-py3-none-any.whl", hash = "sha256:f86dca7d2421273376900f0457736417f3394546d56b2e58e5fdecc06dabfbec"}, + {file = "lightkube-0.15.0.tar.gz", hash = "sha256:c02eba31da578d61aeed35bb65a151ebea854a42e0ade4086b4bf36c8d3f5d29"}, ] [package.dependencies] @@ -1158,13 +1193,13 @@ docs = ["canonical-sphinx-extensions", "furo", "linkify-it-py", "myst-parser", " [[package]] name = "ops-scenario" -version = "6.1.3" +version = "6.1.5" description = "Python library providing a state-transition testing API for Operator Framework charms." optional = false python-versions = ">=3.8" files = [ - {file = "ops_scenario-6.1.3-py3-none-any.whl", hash = "sha256:d8f73d7ba5d65bb9ce2a61d1b6516cefc73cf1b44ea77cab4386beff8c963ab3"}, - {file = "ops_scenario-6.1.3.tar.gz", hash = "sha256:a0754a1a306ffc56ceaeb10964a820bf6248d1d01f4c52a2151cacf3297e29e3"}, + {file = "ops_scenario-6.1.5-py3-none-any.whl", hash = "sha256:22dfa76bc564d4df720085e0fd3f6a685b62012de00a24e98928802436d96a9e"}, + {file = "ops_scenario-6.1.5.tar.gz", hash = "sha256:930fe524d35ce8cb14e1316ff61cdbb5adc76a342503cc746150f2c35d751fdd"}, ] [package.dependencies] @@ -1187,13 +1222,13 @@ files = [ [[package]] name = "paramiko" -version = "3.4.0" +version = "3.4.1" description = "SSH2 protocol library" optional = false python-versions = ">=3.6" files = [ - {file = "paramiko-3.4.0-py3-none-any.whl", hash = "sha256:43f0b51115a896f9c00f59618023484cb3a14b98bbceab43394a39c6739b7ee7"}, - {file = "paramiko-3.4.0.tar.gz", hash = "sha256:aac08f26a31dc4dffd92821527d1682d99d52f9ef6851968114a8728f3c274d3"}, + {file = "paramiko-3.4.1-py3-none-any.whl", hash = "sha256:8e49fd2f82f84acf7ffd57c64311aa2b30e575370dc23bdb375b10262f7eac32"}, + {file = "paramiko-3.4.1.tar.gz", hash = "sha256:8b15302870af7f6652f2e038975c1d2973f06046cb5d7d65355668b3ecbece0c"}, ] [package.dependencies] @@ -1304,24 +1339,53 @@ wcwidth = "*" [[package]] name = "protobuf" -version = "5.27.2" +version = "5.27.3" description = "" optional = false python-versions = ">=3.8" files = [ - {file = "protobuf-5.27.2-cp310-abi3-win32.whl", hash = "sha256:354d84fac2b0d76062e9b3221f4abbbacdfd2a4d8af36bab0474f3a0bb30ab38"}, - {file = "protobuf-5.27.2-cp310-abi3-win_amd64.whl", hash = "sha256:0e341109c609749d501986b835f667c6e1e24531096cff9d34ae411595e26505"}, - {file = "protobuf-5.27.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a109916aaac42bff84702fb5187f3edadbc7c97fc2c99c5ff81dd15dcce0d1e5"}, - {file = "protobuf-5.27.2-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:176c12b1f1c880bf7a76d9f7c75822b6a2bc3db2d28baa4d300e8ce4cde7409b"}, - {file = "protobuf-5.27.2-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:b848dbe1d57ed7c191dfc4ea64b8b004a3f9ece4bf4d0d80a367b76df20bf36e"}, - {file = "protobuf-5.27.2-cp38-cp38-win32.whl", hash = "sha256:4fadd8d83e1992eed0248bc50a4a6361dc31bcccc84388c54c86e530b7f58863"}, - {file = "protobuf-5.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:610e700f02469c4a997e58e328cac6f305f649826853813177e6290416e846c6"}, - {file = "protobuf-5.27.2-cp39-cp39-win32.whl", hash = "sha256:9e8f199bf7f97bd7ecebffcae45ebf9527603549b2b562df0fbc6d4d688f14ca"}, - {file = "protobuf-5.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:7fc3add9e6003e026da5fc9e59b131b8f22b428b991ccd53e2af8071687b4fce"}, - {file = "protobuf-5.27.2-py3-none-any.whl", hash = "sha256:54330f07e4949d09614707c48b06d1a22f8ffb5763c159efd5c0928326a91470"}, - {file = "protobuf-5.27.2.tar.gz", hash = "sha256:f3ecdef226b9af856075f28227ff2c90ce3a594d092c39bee5513573f25e2714"}, + {file = "protobuf-5.27.3-cp310-abi3-win32.whl", hash = "sha256:dcb307cd4ef8fec0cf52cb9105a03d06fbb5275ce6d84a6ae33bc6cf84e0a07b"}, + {file = "protobuf-5.27.3-cp310-abi3-win_amd64.whl", hash = "sha256:16ddf3f8c6c41e1e803da7abea17b1793a97ef079a912e42351eabb19b2cffe7"}, + {file = "protobuf-5.27.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:68248c60d53f6168f565a8c76dc58ba4fa2ade31c2d1ebdae6d80f969cdc2d4f"}, + {file = "protobuf-5.27.3-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:b8a994fb3d1c11156e7d1e427186662b64694a62b55936b2b9348f0a7c6625ce"}, + {file = "protobuf-5.27.3-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:a55c48f2a2092d8e213bd143474df33a6ae751b781dd1d1f4d953c128a415b25"}, + {file = "protobuf-5.27.3-cp38-cp38-win32.whl", hash = "sha256:043853dcb55cc262bf2e116215ad43fa0859caab79bb0b2d31b708f128ece035"}, + {file = "protobuf-5.27.3-cp38-cp38-win_amd64.whl", hash = "sha256:c2a105c24f08b1e53d6c7ffe69cb09d0031512f0b72f812dd4005b8112dbe91e"}, + {file = "protobuf-5.27.3-cp39-cp39-win32.whl", hash = "sha256:c84eee2c71ed83704f1afbf1a85c3171eab0fd1ade3b399b3fad0884cbcca8bf"}, + {file = "protobuf-5.27.3-cp39-cp39-win_amd64.whl", hash = "sha256:af7c0b7cfbbb649ad26132e53faa348580f844d9ca46fd3ec7ca48a1ea5db8a1"}, + {file = "protobuf-5.27.3-py3-none-any.whl", hash = "sha256:8572c6533e544ebf6899c360e91d6bcbbee2549251643d32c52cf8a5de295ba5"}, + {file = "protobuf-5.27.3.tar.gz", hash = "sha256:82460903e640f2b7e34ee81a947fdaad89de796d324bcbc38ff5430bcdead82c"}, ] +[[package]] +name = "psutil" +version = "6.0.0" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "psutil-6.0.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a021da3e881cd935e64a3d0a20983bda0bb4cf80e4f74fa9bfcb1bc5785360c6"}, + {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:1287c2b95f1c0a364d23bc6f2ea2365a8d4d9b726a3be7294296ff7ba97c17f0"}, + {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a9a3dbfb4de4f18174528d87cc352d1f788b7496991cca33c6996f40c9e3c92c"}, + {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6ec7588fb3ddaec7344a825afe298db83fe01bfaaab39155fa84cf1c0d6b13c3"}, + {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e7c870afcb7d91fdea2b37c24aeb08f98b6d67257a5cb0a8bc3ac68d0f1a68c"}, + {file = "psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35"}, + {file = "psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1"}, + {file = "psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e8d0054fc88153ca0544f5c4d554d42e33df2e009c4ff42284ac9ebdef4132"}, + {file = "psutil-6.0.0-cp36-cp36m-win32.whl", hash = "sha256:fc8c9510cde0146432bbdb433322861ee8c3efbf8589865c8bf8d21cb30c4d14"}, + {file = "psutil-6.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:34859b8d8f423b86e4385ff3665d3f4d94be3cdf48221fbe476e883514fdb71c"}, + {file = "psutil-6.0.0-cp37-abi3-win32.whl", hash = "sha256:a495580d6bae27291324fe60cea0b5a7c23fa36a7cd35035a16d93bdcf076b9d"}, + {file = "psutil-6.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:33ea5e1c975250a720b3a6609c490db40dae5d83a4eb315170c4fe0d8b1f34b3"}, + {file = "psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffe7fc9b6b36beadc8c322f84e1caff51e8703b88eee1da46d1e3a6ae11b4fd0"}, + {file = "psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2"}, +] + +[package.extras] +test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -1527,13 +1591,13 @@ pytz = "*" [[package]] name = "pyright" -version = "1.1.372" +version = "1.1.375" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.372-py3-none-any.whl", hash = "sha256:25b15fb8967740f0949fd35b963777187f0a0404c0bd753cc966ec139f3eaa0b"}, - {file = "pyright-1.1.372.tar.gz", hash = "sha256:a9f5e0daa955daaa17e3d1ef76d3623e75f8afd5e37b437d3ff84d5b38c15420"}, + {file = "pyright-1.1.375-py3-none-any.whl", hash = "sha256:4c5e27eddeaee8b41cc3120736a1dda6ae120edf8523bb2446b6073a52f286e3"}, + {file = "pyright-1.1.375.tar.gz", hash = "sha256:7765557b0d6782b2fadabff455da2014476404c9e9214f49977a4e49dec19a0f"}, ] [package.dependencies] @@ -1545,13 +1609,13 @@ dev = ["twine (>=3.4.1)"] [[package]] name = "pytest" -version = "8.3.1" +version = "8.3.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.3.1-py3-none-any.whl", hash = "sha256:e9600ccf4f563976e2c99fa02c7624ab938296551f280835ee6516df8bc4ae8c"}, - {file = "pytest-8.3.1.tar.gz", hash = "sha256:7e8e5c5abd6e93cb1cc151f23e57adc31fcf8cfd2a3ff2da63e23f732de35db6"}, + {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, + {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, ] [package.dependencies] @@ -1602,13 +1666,13 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "pytest-operator" -version = "0.35.0" +version = "0.36.0" description = "Fixtures for Operators" optional = false python-versions = "*" files = [ - {file = "pytest-operator-0.35.0.tar.gz", hash = "sha256:ed963dc013fc576e218081e95197926b7c98116c1fb5ab234269cf72e0746d5b"}, - {file = "pytest_operator-0.35.0-py3-none-any.whl", hash = "sha256:026715faba7a0d725ca386fe05a45cfc73746293d8d755be6d2a67ca252267f5"}, + {file = "pytest_operator-0.36.0-py3-none-any.whl", hash = "sha256:edab65d9d80b19fc85acddeda4a1fd3bb55a781f43ffbb6e9c4002aeeada6ad2"}, + {file = "pytest_operator-0.36.0.tar.gz", hash = "sha256:041eeabd4c9590614f78b7568c74b0d42f768815a6b5c183da8ccfc23345bea9"}, ] [package.dependencies] @@ -1646,62 +1710,64 @@ files = [ [[package]] name = "pyyaml" -version = "6.0.1" +version = "6.0.2" description = "YAML parser and emitter for Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] [[package]] @@ -1977,40 +2043,40 @@ files = [ [[package]] name = "ruff" -version = "0.5.4" +version = "0.5.7" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.5.4-py3-none-linux_armv6l.whl", hash = "sha256:82acef724fc639699b4d3177ed5cc14c2a5aacd92edd578a9e846d5b5ec18ddf"}, - {file = "ruff-0.5.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:da62e87637c8838b325e65beee485f71eb36202ce8e3cdbc24b9fcb8b99a37be"}, - {file = "ruff-0.5.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e98ad088edfe2f3b85a925ee96da652028f093d6b9b56b76fc242d8abb8e2059"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c55efbecc3152d614cfe6c2247a3054cfe358cefbf794f8c79c8575456efe19"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9b85eaa1f653abd0a70603b8b7008d9e00c9fa1bbd0bf40dad3f0c0bdd06793"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cf497a47751be8c883059c4613ba2f50dd06ec672692de2811f039432875278"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:09c14ed6a72af9ccc8d2e313d7acf7037f0faff43cde4b507e66f14e812e37f7"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:628f6b8f97b8bad2490240aa84f3e68f390e13fabc9af5c0d3b96b485921cd60"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3520a00c0563d7a7a7c324ad7e2cde2355733dafa9592c671fb2e9e3cd8194c1"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93789f14ca2244fb91ed481456f6d0bb8af1f75a330e133b67d08f06ad85b516"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:029454e2824eafa25b9df46882f7f7844d36fd8ce51c1b7f6d97e2615a57bbcc"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9492320eed573a13a0bc09a2957f17aa733fff9ce5bf00e66e6d4a88ec33813f"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a6e1f62a92c645e2919b65c02e79d1f61e78a58eddaebca6c23659e7c7cb4ac7"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:768fa9208df2bec4b2ce61dbc7c2ddd6b1be9fb48f1f8d3b78b3332c7d71c1ff"}, - {file = "ruff-0.5.4-py3-none-win32.whl", hash = "sha256:e1e7393e9c56128e870b233c82ceb42164966f25b30f68acbb24ed69ce9c3a4e"}, - {file = "ruff-0.5.4-py3-none-win_amd64.whl", hash = "sha256:58b54459221fd3f661a7329f177f091eb35cf7a603f01d9eb3eb11cc348d38c4"}, - {file = "ruff-0.5.4-py3-none-win_arm64.whl", hash = "sha256:bd53da65f1085fb5b307c38fd3c0829e76acf7b2a912d8d79cadcdb4875c1eb7"}, - {file = "ruff-0.5.4.tar.gz", hash = "sha256:2795726d5f71c4f4e70653273d1c23a8182f07dd8e48c12de5d867bfb7557eed"}, + {file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"}, + {file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"}, + {file = "ruff-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf3d86a1fdac1aec8a3417a63587d93f906c678bb9ed0b796da7b59c1114a1e"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a01c34400097b06cf8a6e61b35d6d456d5bd1ae6961542de18ec81eaf33b4cb8"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc8054f1a717e2213500edaddcf1dbb0abad40d98e1bd9d0ad364f75c763eea"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f70284e73f36558ef51602254451e50dd6cc479f8b6f8413a95fcb5db4a55fc"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a78ad870ae3c460394fc95437d43deb5c04b5c29297815a2a1de028903f19692"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ccd078c66a8e419475174bfe60a69adb36ce04f8d4e91b006f1329d5cd44bcf"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e31c9bad4ebf8fdb77b59cae75814440731060a09a0e0077d559a556453acbb"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d796327eed8e168164346b769dd9a27a70e0298d667b4ecee6877ce8095ec8e"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a09ea2c3f7778cc635e7f6edf57d566a8ee8f485f3c4454db7771efb692c499"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a36d8dcf55b3a3bc353270d544fb170d75d2dff41eba5df57b4e0b67a95bb64e"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9369c218f789eefbd1b8d82a8cf25017b523ac47d96b2f531eba73770971c9e5"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b88ca3db7eb377eb24fb7c82840546fb7acef75af4a74bd36e9ceb37a890257e"}, + {file = "ruff-0.5.7-py3-none-win32.whl", hash = "sha256:33d61fc0e902198a3e55719f4be6b375b28f860b09c281e4bdbf783c0566576a"}, + {file = "ruff-0.5.7-py3-none-win_amd64.whl", hash = "sha256:083bbcbe6fadb93cd86709037acc510f86eed5a314203079df174c40bbbca6b3"}, + {file = "ruff-0.5.7-py3-none-win_arm64.whl", hash = "sha256:2dca26154ff9571995107221d0aeaad0e75a77b5a682d6236cf89a58c70b76f4"}, + {file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"}, ] [[package]] name = "safety" -version = "3.2.4" +version = "3.2.5" description = "Checks installed dependencies for known vulnerabilities and licenses." optional = false python-versions = ">=3.7" files = [ - {file = "safety-3.2.4-py3-none-any.whl", hash = "sha256:242ff7ae448d7fb2ea455c90f44e3f2ca45be9c8559b2fe9dfc89617164a0f17"}, - {file = "safety-3.2.4.tar.gz", hash = "sha256:bac0202016d736a2118057964a0e3983fa20ff2563fd103cac3f3ac1ed3fea11"}, + {file = "safety-3.2.5-py3-none-any.whl", hash = "sha256:eb30e0dff10214f9c119f31927bb16799fab27678c8ccb6d8c78f3bcc4f3da29"}, + {file = "safety-3.2.5.tar.gz", hash = "sha256:656df7b01eb400ddd003190947224183981216bf24ddf4142b819545b9ae5521"}, ] [package.dependencies] @@ -2021,11 +2087,12 @@ filelock = ">=3.12.2,<3.13.0" jinja2 = ">=3.1.0" marshmallow = ">=3.15.0" packaging = ">=21.0" +psutil = ">=6.0.0,<6.1.0" pydantic = ">=1.10.12" requests = "*" rich = "*" "ruamel.yaml" = ">=0.17.21" -safety-schemas = ">=0.0.2" +safety-schemas = ">=0.0.3" setuptools = ">=65.5.1" typer = "*" typing-extensions = ">=4.7.1" @@ -2038,13 +2105,13 @@ spdx = ["spdx-tools (>=0.8.2)"] [[package]] name = "safety-schemas" -version = "0.0.2" +version = "0.0.3" description = "Schemas for Safety tools" optional = false python-versions = ">=3.7" files = [ - {file = "safety_schemas-0.0.2-py3-none-any.whl", hash = "sha256:277c077ce6e53221874a87c29515ffdd2f3773a6db4d035a9f67cc98db3b8c7f"}, - {file = "safety_schemas-0.0.2.tar.gz", hash = "sha256:7d1b040ec06480f05cff6b45ea7a93e09c8942df864fb0d01ddeb67c323cfa8c"}, + {file = "safety_schemas-0.0.3-py3-none-any.whl", hash = "sha256:05b59d3973765911c64541ac6b5aca16418d56c1f043d983a5450ea265916061"}, + {file = "safety_schemas-0.0.3.tar.gz", hash = "sha256:b39554f9c010e4f11f7c9e5773eb31d2c96c164f7a595c273a98b074d0fb98f4"}, ] [package.dependencies] @@ -2056,18 +2123,18 @@ typing-extensions = ">=4.7.1" [[package]] name = "setuptools" -version = "71.1.0" +version = "72.2.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-71.1.0-py3-none-any.whl", hash = "sha256:33874fdc59b3188304b2e7c80d9029097ea31627180896fb549c578ceb8a0855"}, - {file = "setuptools-71.1.0.tar.gz", hash = "sha256:032d42ee9fb536e33087fb66cac5f840eb9391ed05637b3f2a76a7c8fb477936"}, + {file = "setuptools-72.2.0-py3-none-any.whl", hash = "sha256:f11dd94b7bae3a156a95ec151f24e4637fb4fa19c878e4d191bfb8b2d82728c4"}, + {file = "setuptools-72.2.0.tar.gz", hash = "sha256:80aacbf633704e9c8bfa1d99fa5dd4dc59573efcf9e4042c13d3bcef91ac2ef9"}, ] [package.extras] core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] [[package]] @@ -2138,13 +2205,13 @@ pbr = ">=2.0.0,<2.1.0 || >2.1.0" [[package]] name = "tenacity" -version = "8.5.0" +version = "9.0.0" description = "Retry code until it succeeds" optional = false python-versions = ">=3.8" files = [ - {file = "tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687"}, - {file = "tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78"}, + {file = "tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539"}, + {file = "tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b"}, ] [package.extras] @@ -2359,4 +2426,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "3d0e35e24259004e857b4d09d36fc1d1fac04b1764d9d642adb6e1d0881e5bb1" +content-hash = "40cfdcd686e65cd1d56c1cc7bdffdb1d65a71d239c25330d2d8a5d6f21572990" diff --git a/pyproject.toml b/pyproject.toml index 37fde939..d9a49c8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,8 @@ jsonschema = ">=4.10" cryptography = ">42.0.0" pydantic = "^1.10.7" pyyaml = "^6.0.1" -lightkube = "^0.15.0" +requests = ">2.25" +lightkube = "0.15.0" # FIXME: Unpin once rustc 1.76 is available at build time rpds-py = "<0.19" @@ -56,7 +57,10 @@ optional = true black = "^22.3.0" ruff = ">=0.1.0" codespell = ">=2.2.2" -pyright = "^1.1.318" +pyright = "^1.1.301" +lightkube = "0.15.0" +# FIXME: Unpin once rustc 1.76 is available at build time +rpds-py = "<0.19" [tool.poetry.group.unit] optional = true diff --git a/requirements.txt b/requirements.txt index 016c6e0d..7997e570 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ anyio==4.4.0 ; python_version >= "3.10" and python_version < "4.0" -attrs==23.2.0 ; python_version >= "3.10" and python_version < "4.0" +attrs==24.2.0 ; python_version >= "3.10" and python_version < "4.0" certifi==2024.7.4 ; python_version >= "3.10" and python_version < "4.0" -cffi==1.16.0 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy" +cffi==1.17.0 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy" +charset-normalizer==3.3.2 ; python_version >= "3.10" and python_version < "4.0" cryptography==43.0.0 ; python_version >= "3.10" and python_version < "4.0" exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "3.11" h11==0.14.0 ; python_version >= "3.10" and python_version < "4.0" @@ -12,15 +13,17 @@ jsonschema-specifications==2023.12.1 ; python_version >= "3.10" and python_versi jsonschema==4.23.0 ; python_version >= "3.10" and python_version < "4.0" kazoo==2.10.0 ; python_version >= "3.10" and python_version < "4.0" lightkube-models==1.30.0.8 ; python_version >= "3.10" and python_version < "4.0" -lightkube==0.15.3 ; python_version >= "3.10" and python_version < "4.0" +lightkube==0.15.0 ; python_version >= "3.10" and python_version < "4.0" ops==2.15.0 ; python_version >= "3.10" and python_version < "4.0" pure-sasl==0.6.2 ; python_version >= "3.10" and python_version < "4.0" pycparser==2.22 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy" pydantic==1.10.17 ; python_version >= "3.10" and python_version < "4.0" -pyyaml==6.0.1 ; python_version >= "3.10" and python_version < "4.0" +pyyaml==6.0.2 ; python_version >= "3.10" and python_version < "4.0" referencing==0.35.1 ; python_version >= "3.10" and python_version < "4.0" +requests==2.32.3 ; python_version >= "3.10" and python_version < "4.0" rpds-py==0.18.1 ; python_version >= "3.10" and python_version < "4.0" sniffio==1.3.1 ; python_version >= "3.10" and python_version < "4.0" -tenacity==8.5.0 ; python_version >= "3.10" and python_version < "4.0" +tenacity==9.0.0 ; python_version >= "3.10" and python_version < "4.0" typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "4.0" +urllib3==2.2.2 ; python_version >= "3.10" and python_version < "4.0" websocket-client==1.8.0 ; python_version >= "3.10" and python_version < "4.0" diff --git a/src/charm.py b/src/charm.py index 9f027dc2..3f191e3e 100755 --- a/src/charm.py +++ b/src/charm.py @@ -56,6 +56,7 @@ def __init__(self, *args): self.workload = KafkaWorkload( container=self.unit.get_container(CONTAINER) ) # Will be re-instantiated for each role. + self.restart = RollingOpsManager(self, relation="restart", callback=self._restart_broker) self.framework.observe(getattr(self.on, "config_changed"), self._on_roles_changed) diff --git a/src/core/cluster.py b/src/core/cluster.py index e24fb5d8..2b38c59a 100644 --- a/src/core/cluster.py +++ b/src/core/cluster.py @@ -6,6 +6,7 @@ import os from functools import cached_property +from ipaddress import IPv4Address, IPv6Address from typing import TYPE_CHECKING, Any from charms.data_platform_libs.v0.data_interfaces import ( @@ -44,7 +45,7 @@ SECRETS_UNIT, SECURITY_PROTOCOL_PORTS, ZK, - AuthMechanism, + AuthMap, Status, Substrates, ) @@ -92,6 +93,7 @@ def __init__(self, charm: "KafkaCharm", substrate: Substrates): self.substrate: Substrates = substrate self.roles = charm.config.roles self.network_bandwidth = charm.config.network_bandwidth + self.config = charm.config self.peer_app_interface = DataPeerData(self.model, relation_name=PEER) self.peer_unit_interface = DataPeerUnitData( @@ -288,6 +290,16 @@ def clients(self) -> set[KafkaClient]: # ---- GENERAL VALUES ---- + @property + def bind_address(self) -> IPv4Address | IPv6Address | str: + """The network binding address from the peer relation.""" + bind_address = None + if self.peer_relation: + if binding := self.model.get_binding(self.peer_relation): + bind_address = binding.network.bind_address + + return bind_address or "" + @property def super_users(self) -> str: """Generates all users with super/admin permissions for the cluster from relations. @@ -313,13 +325,41 @@ def super_users(self) -> str: return ";".join(super_users_arg) @property - def port(self) -> int: - """Return the port to be used internally.""" - mechanism: AuthMechanism = "SCRAM-SHA-512" - return ( - SECURITY_PROTOCOL_PORTS["SASL_SSL", mechanism].client - if (self.cluster.tls_enabled and self.unit_broker.certificate) - else SECURITY_PROTOCOL_PORTS["SASL_PLAINTEXT", mechanism].client + def default_auth(self) -> AuthMap: + """The current enabled auth.protocol for bootstrap.""" + auth_protocol = ( + "SASL_SSL" + if self.cluster.tls_enabled and self.unit_broker.certificate + else "SASL_PLAINTEXT" + ) + + # FIXME: will need updating when we support multiple concurrent security.protocols + # as this is what is sent across the relation, currently SASL only + return AuthMap(auth_protocol, "SCRAM-SHA-512") + + @property + def enabled_auth(self) -> list[AuthMap]: + """The currently enabled auth.protocols and their auth.mechanisms, based on related applications.""" + enabled_auth = [] + if self.client_relations or self.runs_balancer or self.peer_cluster_relation: + enabled_auth.append(self.default_auth) + if self.oauth_relation: + enabled_auth.append(AuthMap(self.default_auth.protocol, "OAUTHBEARER")) + if self.cluster.mtls_enabled: + enabled_auth.append(AuthMap("SSL", "SSL")) + + return enabled_auth + + @property + def bootstrap_servers_external(self) -> str: + """Comma-delimited string of `bootstrap-server` for external access.""" + return ",".join( + sorted( + { + f"{broker.node_ip}:{self.unit_broker.k8s.get_bootstrap_nodeport(self.default_auth)}" + for broker in self.brokers + } + ) ) @property @@ -332,7 +372,17 @@ def bootstrap_server(self) -> str: if not self.peer_relation: return "" - return ",".join(sorted([f"{broker.host}:{self.port}" for broker in self.brokers])) + if self.config.expose_external: # implicitly checks for k8s in structured_config + return self.bootstrap_servers_external + + return ",".join( + sorted( + [ + f"{broker.internal_address}:{SECURITY_PROTOCOL_PORTS[self.default_auth].client}" + for broker in self.brokers + ] + ) + ) @property def log_dirs(self) -> str: diff --git a/src/core/models.py b/src/core/models.py index acd995e5..562fc6b6 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -6,6 +6,7 @@ import json import logging +from functools import cached_property from typing import MutableMapping, TypeAlias import requests @@ -16,11 +17,19 @@ ) from charms.zookeeper.v0.client import QuorumLeaderNotFoundError, ZooKeeperManager from kazoo.client import AuthFailedError, NoNodeError +from lightkube.resources.core_v1 import Node, Pod from ops.model import Application, Relation, Unit from tenacity import retry, retry_if_result, stop_after_attempt, wait_fixed from typing_extensions import override -from literals import BALANCER, BROKER, INTERNAL_USERS, SECRETS_APP, Substrates +from literals import ( + BALANCER, + BROKER, + INTERNAL_USERS, + SECRETS_APP, + Substrates, +) +from managers.k8s import K8sManager logger = logging.getLogger(__name__) @@ -403,6 +412,10 @@ def __init__( super().__init__(relation, data_interface, component, substrate) self.data_interface = data_interface self.unit = component + self.k8s = K8sManager( + pod_name=self.pod_name, + namespace=self.unit._backend.model_name, + ) @property def unit_id(self) -> int: @@ -413,17 +426,26 @@ def unit_id(self) -> int: return int(self.unit.name.split("/")[1]) @property - def host(self) -> str: - """Return the hostname of a unit.""" - host = "" + def internal_address(self) -> str: + """The address for internal communication between brokers.""" + addr = "" if self.substrate == "vm": for key in ["hostname", "ip", "private-address"]: - if host := self.relation_data.get(key, ""): + if addr := self.relation_data.get(key, ""): break + if self.substrate == "k8s": - host = f"{self.unit.name.split('/')[0]}-{self.unit_id}.{self.unit.name.split('/')[0]}-endpoints" + addr = f"{self.unit.name.split('/')[0]}-{self.unit_id}.{self.unit.name.split('/')[0]}-endpoints" + + return addr - return host + @property + def host(self) -> str: + """Return the hostname of a unit.""" + if self.substrate == "vm": + return self.internal_address + else: + return self.node_ip or self.internal_address # --- TLS --- @@ -503,6 +525,38 @@ def rack(self) -> str: """The rack for the broker on broker.rack from rack.properties.""" return self.relation_data.get("rack", "") + @property + def pod_name(self) -> str: + """The name of the K8s Pod for the unit. + + K8s-only. + """ + return self.unit.name.replace("/", "-") + + @cached_property + def pod(self) -> Pod: + """The Pod of the unit. + + K8s-only. + """ + return self.k8s.get_pod(pod_name=self.pod_name) + + @cached_property + def node(self) -> Node: + """The Node the unit is scheduled on. + + K8s-only. + """ + return self.k8s.get_node(pod=self.pod) + + @cached_property + def node_ip(self) -> str: + """The IPV4/IPV6 IP address the Node the unit is on. + + K8s-only. + """ + return self.k8s.get_node_ip(node=self.node) + class ZooKeeper(RelationState): """State collection metadata for a the Zookeeper relation.""" diff --git a/src/core/structured_config.py b/src/core/structured_config.py index bf1489e0..9b102046 100644 --- a/src/core/structured_config.py +++ b/src/core/structured_config.py @@ -10,7 +10,7 @@ from charms.data_platform_libs.v0.data_models import BaseConfigModel from pydantic import Field, validator -from literals import BALANCER, BROKER +from literals import BALANCER, BROKER, SUBSTRATE logger = logging.getLogger(__name__) @@ -78,6 +78,7 @@ class CharmConfig(BaseConfigModel): network_bandwidth: int = Field(default=50000, validate_default=False, gt=0) cruisecontrol_balance_threshold: float = Field(default=1.1, validate_default=False, ge=1) cruisecontrol_capacity_threshold: float = Field(default=0.8, validate_default=False, le=1) + expose_external: str | None @validator("*", pre=True) @classmethod @@ -230,6 +231,18 @@ def profile_values(cls, value: str) -> str | None: return value + @validator("expose_external") + @classmethod + def expose_external_validator(cls, value: str) -> str | None: + """Check expose-external config option is only used on Kubernetes charm.""" + if SUBSTRATE == "vm": + return + + if value == "none": + return + + return value + @validator("log_level") @classmethod def log_level_values(cls, value: str) -> str | None: diff --git a/src/core/workload.py b/src/core/workload.py index 6d4820e8..53374f54 100644 --- a/src/core/workload.py +++ b/src/core/workload.py @@ -4,11 +4,14 @@ """Supporting objects for Kafka charm state.""" +import re import secrets import string from abc import ABC, abstractmethod -from literals import Role +from ops.pebble import Layer + +from literals import BALANCER, BROKER, Role class CharmedKafkaPaths: @@ -87,12 +90,12 @@ def jmx_prometheus_javaagent(self): @property def jmx_prometheus_config(self): """The configuration for the Kafka JMX exporter.""" - return f"{self.conf_path}/jmx_prometheus.yaml" + return f"{BROKER.paths['CONF']}/jmx_prometheus.yaml" @property def jmx_cc_config(self): """The configuration for the CruiseControl JMX exporter.""" - return f"{self.conf_path}/jmx_cruise_control.yaml" + return f"{BALANCER.paths['CONF']}/jmx_cruise_control.yaml" @property def cruise_control_properties(self): @@ -183,13 +186,31 @@ def run_bin_command(self, bin_keyword: str, bin_args: list[str], opts: list[str] """ ... - @abstractmethod def get_version(self) -> str: """Get the workload version. Returns: String of kafka version """ + if not self.active: + return "" + + try: + version = re.split(r"[\s\-]", self.run_bin_command("topics", ["--version"]))[0] + except: # noqa: E722 + version = "" + return version + + @property + @abstractmethod + def layer(self) -> Layer: + """Gets the Pebble Layer definition for the current workload.""" + ... + + @property + @abstractmethod + def container_can_connect(self) -> bool: + """Flag to check if workload container can connect.""" ... @staticmethod diff --git a/src/events/balancer.py b/src/events/balancer.py index 5af34623..565149f0 100644 --- a/src/events/balancer.py +++ b/src/events/balancer.py @@ -1,3 +1,7 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + """Balancer role core charm logic.""" import logging @@ -10,19 +14,18 @@ EventBase, InstallEvent, Object, - pebble, + PebbleReadyEvent, + StartEvent, ) -from ops.pebble import ExecError, Layer +from ops.pebble import ExecError from literals import ( BALANCER, BALANCER_WEBSERVER_PORT, BALANCER_WEBSERVER_USER, CONTAINER, - GROUP, MODE_ADD, MODE_REMOVE, - USER, Status, ) from managers.balancer import BalancerManager @@ -43,11 +46,19 @@ def __init__(self, charm) -> None: super().__init__(charm, BALANCER.value) self.charm: "KafkaCharm" = charm - self.workload = BalancerWorkload(container=self.charm.unit.get_container(CONTAINER)) + self.workload = BalancerWorkload( + container=self.charm.unit.get_container(CONTAINER) + if self.charm.substrate == "k8s" + else None + ) self.tls_manager = TLSManager( - state=self.charm.state, workload=self.workload, substrate=self.charm.substrate + state=self.charm.state, + workload=self.workload, + substrate=self.charm.substrate, + config=self.charm.config, ) + # Fast exit after workload instantiation, but before any event observer if BALANCER.value not in self.charm.config.roles or not self.charm.unit.is_leader(): return @@ -59,9 +70,10 @@ def __init__(self, charm) -> None: self.framework.observe(self.charm.on.install, self._on_install) self.framework.observe(self.charm.on.start, self._on_start) - self.framework.observe( - getattr(self.charm.on, "kafka_pebble_ready"), self._on_kafka_pebble_ready - ) + + if self.charm.substrate == "k8s": + self.framework.observe(getattr(self.charm.on, "kafka_pebble_ready"), self._on_start) + self.framework.observe(self.charm.on.leader_elected, self._on_start) # ensures data updates, eventually @@ -70,51 +82,16 @@ def __init__(self, charm) -> None: self.framework.observe(getattr(self.charm.on, "rebalance_action"), self.rebalance) - @property - def _balancer_layer(self) -> Layer: - """Returns a Pebble configuration layer for CruiseControl.""" - extra_opts = [ - # FIXME: Port already in use by the broker. To be fixed once we have CC_JMX_OPTS - # f"-javaagent:{CharmedKafkaPaths(BROKER).jmx_prometheus_javaagent}={JMX_EXPORTER_PORT}:{CharmedKafkaPaths(BROKER).jmx_prometheus_config}", - f"-Djava.security.auth.login.config={self.workload.paths.balancer_jaas}", - ] - command = f"{self.workload.paths.binaries_path}/bin/kafka-cruise-control-start.sh {self.workload.paths.cruise_control_properties}" - - layer_config: pebble.LayerDict = { - "summary": "kafka layer", - "description": "Pebble config layer for kafka", - "services": { - BALANCER.service: { - "override": "merge", - "summary": "balancer", - "command": command, - "startup": "enabled", - "user": USER, - "group": GROUP, - "environment": { - "KAFKA_OPTS": " ".join(extra_opts), - # FIXME https://github.com/canonical/kafka-k8s-operator/issues/80 - "JAVA_HOME": "/usr/lib/jvm/java-18-openjdk-amd64", - "LOG_DIR": self.workload.paths.logs_path, - }, - } - }, - } - return Layer(layer_config) - def _on_install(self, event: InstallEvent) -> None: """Handler for `install` event.""" - if not self.charm.unit.get_container(CONTAINER).can_connect(): + if not self.workload.container_can_connect: event.defer() return self.config_manager.set_environment() - def _on_start(self, event: EventBase) -> None: - """Handler for `start` event.""" - self._on_kafka_pebble_ready(event) - - def _on_kafka_pebble_ready(self, event): + def _on_start(self, event: StartEvent | PebbleReadyEvent) -> None: + """Handler for `start` or `pebble-ready` events.""" self.charm._set_status(self.charm.state.ready_to_start) if not isinstance(self.charm.unit.status, ActiveStatus): event.defer() @@ -144,14 +121,18 @@ def _on_kafka_pebble_ready(self, event): event.defer() return - self.workload.start(layer=self._balancer_layer) + self.workload.start() logger.info("CruiseControl service started") - def _on_config_changed(self, event: EventBase) -> None: + def _on_config_changed(self, _: EventBase) -> None: """Generic handler for 'something changed' events.""" + if not self.charm.unit.is_leader(): + return + if not self.healthy: return + # NOTE: smells like a good abstraction somewhere changed_map = [ ( "properties", @@ -159,7 +140,7 @@ def _on_config_changed(self, event: EventBase) -> None: self.config_manager.cruise_control_properties, ), ( - "jass", + "jaas", self.workload.paths.balancer_jaas, self.config_manager.jaas_config.splitlines(), ), @@ -172,7 +153,7 @@ def _on_config_changed(self, event: EventBase) -> None: logger.info( ( f"Balancer {self.charm.unit.name.split('/')[1]} updating config - " - f"OLD {kind.upper()} = {set(map(str.strip, file_content)) - set(map(str.strip, state_content))}" + f"OLD {kind.upper()} = {set(map(str.strip, file_content)) - set(map(str.strip, state_content))}, " f"NEW {kind.upper()} = {set(map(str.strip, state_content)) - set(map(str.strip, file_content))}" ) ) @@ -184,7 +165,7 @@ def _on_config_changed(self, event: EventBase) -> None: self.config_manager.set_broker_capacities() self.config_manager.set_zk_jaas_config() - self._on_start(event) + self.charm.on.start.emit() def rebalance(self, event: ActionEvent) -> None: """Handles the `rebalance` Juju Action.""" @@ -249,6 +230,7 @@ def healthy(self) -> bool: Returns: True if service is alive and active. Otherwise False """ + # needed in case it's called by BrokerOperator in set_client_data if not self.charm.state.runs_balancer: return True diff --git a/src/events/broker.py b/src/events/broker.py index 71fc90df..88674ef3 100644 --- a/src/events/broker.py +++ b/src/events/broker.py @@ -1,44 +1,49 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + """Broker role core charm logic.""" import json import logging +from datetime import datetime from typing import TYPE_CHECKING +from charms.operator_libs_linux.v1.snap import SnapError from ops import ( ActiveStatus, EventBase, InstallEvent, Object, + PebbleReadyEvent, SecretChangedEvent, StartEvent, - StatusBase, StorageAttachedEvent, StorageDetachingEvent, + StorageEvent, UpdateStatusEvent, - pebble, ) -from ops.pebble import Layer from events.oauth import OAuthHandler from events.password_actions import PasswordActionEvents from events.provider import KafkaProvider from events.upgrade import KafkaDependencyModel, KafkaUpgrade from events.zookeeper import ZooKeeperHandler +from health import KafkaHealth from literals import ( BROKER, CONTAINER, DEPENDENCIES, GROUP, - JMX_EXPORTER_PORT, PEER, REL_NAME, USER, - DebugLevel, Status, ) from managers.auth import AuthManager from managers.balancer import BalancerManager from managers.config import ConfigManager +from managers.k8s import K8sManager from managers.tls import TLSManager from workload import KafkaWorkload @@ -55,15 +60,24 @@ def __init__(self, charm) -> None: super().__init__(charm, BROKER.value) self.charm: "KafkaCharm" = charm - self.workload = KafkaWorkload(container=self.charm.unit.get_container(CONTAINER)) + self.workload = KafkaWorkload( + container=self.charm.unit.get_container(CONTAINER) + if self.charm.substrate == "k8s" + else None + ) self.tls_manager = TLSManager( - state=self.charm.state, workload=self.workload, substrate=self.charm.substrate + state=self.charm.state, + workload=self.workload, + substrate=self.charm.substrate, + config=self.charm.config, ) + # Fast exit after workload instantiation, but before any event observer if BROKER.value not in self.charm.config.roles: return + self.health = KafkaHealth(self) if self.charm.substrate == "vm" else None self.upgrade = KafkaUpgrade( self, substrate=self.charm.substrate, @@ -90,16 +104,17 @@ def __init__(self, charm) -> None: kafka_opts=self.config_manager.kafka_opts, log4j_opts=self.config_manager.tools_log4j_opts, ) - + self.k8s_manager = K8sManager( + pod_name=self.charm.state.unit_broker.pod_name, namespace=self.charm.model.name + ) self.balancer_manager = BalancerManager(self) - # --- - self.framework.observe(getattr(self.charm.on, "install"), self._on_install) self.framework.observe(getattr(self.charm.on, "start"), self._on_start) - self.framework.observe( - getattr(self.charm.on, "kafka_pebble_ready"), self._on_kafka_pebble_ready - ) + + if self.charm.substrate == "k8s": + self.framework.observe(getattr(self.charm.on, "kafka_pebble_ready"), self._on_start) + self.framework.observe(getattr(self.charm.on, "config_changed"), self._on_config_changed) self.framework.observe(getattr(self.charm.on, "update_status"), self._on_update_status) self.framework.observe(getattr(self.charm.on, "secret_changed"), self._on_secret_changed) @@ -113,48 +128,40 @@ def __init__(self, charm) -> None: getattr(self.charm.on, "data_storage_detaching"), self._on_storage_detaching ) - @property - def _kafka_layer(self) -> Layer: - """Returns a Pebble configuration layer for Kafka.""" - extra_opts = [ - f"-javaagent:{self.workload.paths.jmx_prometheus_javaagent}={JMX_EXPORTER_PORT}:{self.workload.paths.jmx_prometheus_config}", - f"-Djava.security.auth.login.config={self.workload.paths.zk_jaas}", - ] - command = f"{self.workload.paths.binaries_path}/bin/kafka-server-start.sh {self.workload.paths.server_properties}" - - layer_config: pebble.LayerDict = { - "summary": "kafka layer", - "description": "Pebble config layer for kafka", - "services": { - BROKER.service: { - "override": "merge", - "summary": "kafka", - "command": command, - "startup": "enabled", - "user": USER, - "group": GROUP, - "environment": { - "KAFKA_OPTS": " ".join(extra_opts), - # FIXME https://github.com/canonical/kafka-k8s-operator/issues/80 - "JAVA_HOME": "/usr/lib/jvm/java-18-openjdk-amd64", - "LOG_DIR": self.workload.paths.logs_path, - }, - } - }, - } - return Layer(layer_config) + def _on_install(self, event: InstallEvent) -> None: + """Handler for `install` event.""" + if not self.workload.container_can_connect: + event.defer() + return - def _on_kafka_pebble_ready(self, event: EventBase) -> None: - """Handler for `start` event.""" - # don't want to run default pebble ready during upgrades + self.charm.unit.set_workload_version(self.workload.get_version()) + self.config_manager.set_environment() + + # any external services must be created before setting of properties + self.update_external_services() + + def _on_start(self, event: StartEvent | PebbleReadyEvent) -> None: + """Handler for `start` or `pebble-ready` events.""" + if not self.workload.container_can_connect: + event.defer() + return + + if self.charm.state.peer_relation: + self.charm.state.unit_broker.update( + {"cores": str(self.balancer_manager.cores), "rack": self.config_manager.rack} + ) + + # don't want to run default start/pebble-ready events during upgrades if not self.upgrade.idle: return - self._set_status(self.charm.state.ready_to_start) + self.charm._set_status(self.charm.state.ready_to_start) if not isinstance(self.charm.unit.status, ActiveStatus): event.defer() return + self.update_external_services() + # required settings given zookeeper connection config has been created self.config_manager.set_server_properties() self.config_manager.set_zk_jaas_config() @@ -174,35 +181,19 @@ def _on_kafka_pebble_ready(self, event: EventBase) -> None: self.tls_manager.set_keystore() # start kafka service - self.workload.start(layer=self._kafka_layer) + self.workload.start() logger.info("Kafka service started") # service_start might fail silently, confirm with ZK if kafka is actually connected self.charm.on.update_status.emit() - def _on_start(self, event: StartEvent) -> None: - """Wrapper for start event.""" - if not self.charm.unit.get_container(CONTAINER).can_connect(): - event.defer() - return - - if self.charm.state.peer_relation: - self.charm.state.unit_broker.update( - {"cores": str(self.balancer_manager.cores), "rack": self.config_manager.rack} - ) - self._on_kafka_pebble_ready(event) - - def _on_install(self, event: InstallEvent) -> None: - """Handler for `install` event.""" - if not self.charm.unit.get_container(CONTAINER).can_connect(): - event.defer() - return - - self.charm.unit.set_workload_version(self.workload.get_version()) - self.config_manager.set_environment() + # only log once on successful 'on-start' run + if isinstance(self.charm.unit.status, ActiveStatus): + logger.info(f'Broker {self.charm.unit.name.split("/")[1]} connected') def _on_config_changed(self, event: EventBase) -> None: """Generic handler for most `config_changed` events across relations.""" + # only overwrite properties if service is already active if not self.upgrade.idle or not self.healthy: event.defer() return @@ -214,15 +205,39 @@ def _on_config_changed(self, event: EventBase) -> None: zk_jaas = self.workload.read(self.workload.paths.zk_jaas) zk_jaas_changed = set(zk_jaas) ^ set(self.config_manager.jaas_config.splitlines()) - if not properties or not zk_jaas: + current_sans = self.tls_manager.get_current_sans() + + if not (properties and zk_jaas): # Event fired before charm has properly started event.defer() return + current_sans_ip = set(current_sans["sans_ip"]) if current_sans else set() + expected_sans_ip = set(self.tls_manager.build_sans()["sans_ip"]) if current_sans else set() + sans_ip_changed = current_sans_ip ^ expected_sans_ip + # update environment self.config_manager.set_environment() self.charm.unit.set_workload_version(self.workload.get_version()) + if sans_ip_changed: + logger.info( + ( + f'Broker {self.charm.unit.name.split("/")[1]} updating certificate SANs - ' + f"OLD SANs = {current_sans_ip - expected_sans_ip}, " + f"NEW SANs = {expected_sans_ip - current_sans_ip}" + ) + ) + self.charm.tls.certificates.on.certificate_expiring.emit( + certificate=self.charm.state.unit_broker.certificate, + expiry=datetime.now().isoformat(), + ) # new cert will eventually be dynamically loaded by the broker + self.charm.state.unit_broker.update( + {"certificate": ""} + ) # ensures only single requested new certs, will be replaced on new certificate-available event + + return # early return here to ensure new node cert arrives before updating advertised.listeners + if zk_jaas_changed: clean_broker_jaas = [conf.strip() for conf in zk_jaas] clean_config_jaas = [ @@ -248,10 +263,16 @@ def _on_config_changed(self, event: EventBase) -> None: self.config_manager.set_server_properties() if zk_jaas_changed or properties_changed: - self.charm.on[f"{self.charm.restart.name}"].acquire_lock.emit() + if isinstance(event, StorageEvent): # to get new storages + self.charm.on[f"{self.charm.restart.name}"].acquire_lock.emit( + callback_override="_disable_enable_restart_broker" + ) + else: + self.charm.on[f"{self.charm.restart.name}"].acquire_lock.emit() - # update client_properties whenever possible + # update these whenever possible self.config_manager.set_client_properties() + self.update_external_services() # If Kafka is related to client charms, update their information. if self.model.relations.get(REL_NAME, None) and self.charm.unit.is_leader(): @@ -266,14 +287,23 @@ def _on_update_status(self, _: UpdateStatusEvent) -> None: return if not self.charm.state.zookeeper.broker_active(): - self._set_status(Status.ZK_NOT_CONNECTED) + self.charm._set_status(Status.ZK_NOT_CONNECTED) return # NOTE for situations like IP change and late integration with rack-awareness charm. # If properties have changed, the broker will restart. self.charm.on.config_changed.emit() - self._set_status(Status.ACTIVE) + try: + if self.health and not self.health.machine_configured(): + self.charm._set_status(Status.SYSCONF_NOT_OPTIMAL) + return + except SnapError as e: + logger.debug(f"Error: {e}") + self.charm._set_status(Status.BROKER_NOT_RUNNING) + return + + self.charm._set_status(Status.ACTIVE) def _on_secret_changed(self, event: SecretChangedEvent) -> None: """Handler for `secret_changed` events.""" @@ -290,45 +320,47 @@ def _on_secret_changed(self, event: SecretChangedEvent) -> None: def _on_storage_attached(self, event: StorageAttachedEvent) -> None: """Handler for `storage_attached` events.""" - if ( - not self.charm.unit.get_container(CONTAINER).can_connect() - or not self.charm.state.peer_relation - ): + if not self.workload.container_can_connect or not self.charm.state.peer_relation: event.defer() return self.charm.state.unit_broker.update({"storages": self.balancer_manager.storages}) + + if self.charm.substrate == "vm": + # new dirs won't be used until topic partitions are assigned to it + # either automatically for new topics, or manually for existing + # set status only for running services, not on startup + self.workload.exec(["chmod", "-R", "750", f"{self.workload.paths.data_path}"]) + self.workload.exec( + ["chown", "-R", f"{USER}:{GROUP}", f"{self.workload.paths.data_path}"] + ) + self.workload.exec( + [ + "bash", + "-c", + f"""find {self.workload.paths.data_path} -type f -name meta.properties -delete || true""", + ] + ) + # checks first whether the broker is active before warning if self.workload.active(): # new dirs won't be used until topic partitions are assigned to it # either automatically for new topics, or manually for existing - self._set_status(Status.ADDED_STORAGE) + self.charm._set_status(Status.ADDED_STORAGE) + # We need the event handler to know about the original event self._on_config_changed(event) def _on_storage_detaching(self, _: StorageDetachingEvent) -> None: """Handler for `storage_detaching` events.""" # in the case where there may be replication recovery may be possible - if self.charm.state.peer_relation and len(self.charm.state.peer_relation.units): - self._set_status(Status.REMOVED_STORAGE) + if self.charm.state.brokers and len(self.charm.state.brokers) > 1: + self.charm._set_status(Status.REMOVED_STORAGE) else: - self._set_status(Status.REMOVED_STORAGE_NO_REPL) + self.charm._set_status(Status.REMOVED_STORAGE_NO_REPL) + self.charm.state.unit_broker.update({"storages": self.balancer_manager.storages}) self.charm.on.config_changed.emit() - def _restart(self, event: EventBase) -> None: - """Handler for `rolling_ops` restart events.""" - # only attempt restart if service is already active - if not self.healthy: - event.defer() - return - - self.workload.restart() - - if self.healthy: - logger.info(f'Broker {self.charm.unit.name.split("/")[1]} restarted') - else: - logger.error(f"Broker {self.charm.unit.name.split('/')[1]} failed to restart") - @property def healthy(self) -> bool: """Checks and updates various charm lifecycle states. @@ -338,16 +370,31 @@ def healthy(self) -> bool: Returns: True if service is alive and active. Otherwise False """ - self._set_status(self.charm.state.ready_to_start) + self.charm._set_status(self.charm.state.ready_to_start) if not isinstance(self.charm.unit.status, ActiveStatus): return False if not self.workload.active(): - self._set_status(Status.BROKER_NOT_RUNNING) + self.charm._set_status(Status.BROKER_NOT_RUNNING) return False return True + def update_external_services(self) -> None: + """Attempts to update any external Kubernetes services.""" + if not self.charm.substrate == "k8s": + return + + if self.charm.config.expose_external: + # every unit attempts to create a bootstrap service + # if exists, will silently continue + self.k8s_manager.apply_service(service=self.k8s_manager.build_bootstrap_services()) + + # creating the per-broker listener services + for auth in self.charm.state.enabled_auth: + listener_service = self.k8s_manager.build_listener_service(auth) + self.k8s_manager.apply_service(service=listener_service) + def update_client_data(self) -> None: """Writes necessary relation data to all related client applications.""" if not self.charm.unit.is_leader() or not self.healthy or not self.charm.balancer.healthy: @@ -393,11 +440,3 @@ def update_peer_cluster_data(self) -> None: ) # self.charm.on.config_changed.emit() # ensure both broker+balancer get a changed event - - def _set_status(self, key: Status) -> None: - """Sets charm status.""" - status: StatusBase = key.value.status - log_level: DebugLevel = key.value.log_level - - getattr(logger, log_level.lower())(status.message) - self.charm.unit.status = status diff --git a/src/events/oauth.py b/src/events/oauth.py index 24a680f9..9e611695 100644 --- a/src/events/oauth.py +++ b/src/events/oauth.py @@ -1,4 +1,5 @@ -# Copyright 2023 Canonical Ltd. +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. """Manager for handling Kafka OAuth configuration.""" diff --git a/src/events/peer_cluster.py b/src/events/peer_cluster.py index ccb92170..43d52d75 100644 --- a/src/events/peer_cluster.py +++ b/src/events/peer_cluster.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. +# Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. """KafkaProvider class and methods.""" diff --git a/src/events/tls.py b/src/events/tls.py index 86dc972a..d3d906e5 100644 --- a/src/events/tls.py +++ b/src/events/tls.py @@ -9,11 +9,11 @@ import logging import os import re -import socket from typing import TYPE_CHECKING from charms.tls_certificates_interface.v1.tls_certificates import ( CertificateAvailableEvent, + EventBase, TLSCertificatesRequiresV1, _load_relation_data, generate_csr, @@ -26,9 +26,9 @@ RelationJoinedEvent, ) from ops.framework import Object -from ops.model import ActiveStatus, BlockedStatus +from ops.model import ActiveStatus -from literals import TLS_RELATION, TRUSTED_CA_RELATION, TRUSTED_CERTIFICATE_RELATION +from literals import TLS_RELATION, TRUSTED_CA_RELATION, TRUSTED_CERTIFICATE_RELATION, Status if TYPE_CHECKING: from charm import KafkaCharm @@ -126,15 +126,14 @@ def _tls_relation_broken(self, _) -> None: self.charm.state.cluster.update({"tls": ""}) - def _trusted_relation_created(self, _) -> None: + def _trusted_relation_created(self, event: EventBase) -> None: """Handle relation created event to trusted tls charm.""" if not self.charm.unit.is_leader(): return if not self.charm.state.cluster.tls_enabled: - msg = "Own certificates are not set. Please relate using 'certificates' relation first" - logger.error(msg) - self.charm.app.status = BlockedStatus(msg) + self.charm._set_status(Status.NO_CERT) + event.defer() return # Create a "mtls" flag so a new listener (CLIENT_SSL) is created @@ -145,7 +144,7 @@ def _trusted_relation_joined(self, event: RelationJoinedEvent) -> None: """Generate a CSR so the tls-certificates operator works as expected.""" # Once the certificates have been added, TLS setup has finished if not self.charm.state.unit_broker.certificate: - logger.debug("Missing TLS relation, deferring") + self.charm._set_status(Status.NO_CERT) event.defer() return @@ -156,13 +155,14 @@ def _trusted_relation_joined(self, event: RelationJoinedEvent) -> None: subject = ( os.uname()[1] if self.charm.substrate == "k8s" else self.charm.state.unit_broker.host ) + sans = self.charm.broker.tls_manager.build_sans() csr = ( generate_csr( add_unique_id_to_subject_name=bool(alias), private_key=self.charm.state.unit_broker.private_key.encode("utf-8"), subject=subject, - sans_ip=self._sans["sans_ip"], - sans_dns=self._sans["sans_dns"], + sans_ip=sans["sans_ip"], + sans_dns=sans["sans_dns"], ) .decode() .strip() @@ -274,27 +274,7 @@ def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: def _on_certificate_expiring(self, _) -> None: """Handler for `certificate_expiring` event.""" - if ( - not self.charm.state.unit_broker.private_key - or not self.charm.state.unit_broker.csr - or not self.charm.state.peer_relation - ): - logger.error("Missing unit private key and/or old csr") - return - - new_csr = generate_csr( - private_key=self.charm.state.unit_broker.private_key.encode("utf-8"), - subject=self.charm.state.unit_broker.relation_data.get("private-address", ""), - sans_ip=self._sans["sans_ip"], - sans_dns=self._sans["sans_dns"], - ) - - self.certificates.request_certificate_renewal( - old_certificate_signing_request=self.charm.state.unit_broker.csr.encode("utf-8"), - new_certificate_signing_request=new_csr, - ) - - self.charm.state.unit_broker.update({"csr": new_csr.decode("utf-8").strip()}) + self._request_certificate_renewal() def _set_tls_private_key(self, event: ActionEvent) -> None: """Handler for `set_tls_private_key` action.""" @@ -314,49 +294,39 @@ def _request_certificate(self): logger.error("Can't request certificate, missing private key") return + sans = self.charm.broker.tls_manager.build_sans() + csr = generate_csr( private_key=self.charm.state.unit_broker.private_key.encode("utf-8"), subject=self.charm.state.unit_broker.relation_data.get("private-address", ""), - sans_ip=self._sans["sans_ip"], - sans_dns=self._sans["sans_dns"], + sans_ip=sans["sans_ip"], + sans_dns=sans["sans_dns"], ) self.charm.state.unit_broker.update({"csr": csr.decode("utf-8").strip()}) self.certificates.request_certificate_creation(certificate_signing_request=csr) - @property - def _sans(self) -> dict[str, list[str] | None]: - """Builds a SAN dict of DNS names and IPs for the unit.""" - if self.charm.substrate == "vm": - return { - "sans_ip": [self.charm.state.unit_broker.host], - "sans_dns": [self.model.unit.name, socket.getfqdn()] + self._extra_sans, - } - else: - bind_address = "" - if self.charm.state.peer_relation: - if binding := self.charm.model.get_binding(self.charm.state.peer_relation): - bind_address = binding.network.bind_address - return { - "sans_ip": [str(bind_address)], - "sans_dns": [ - self.charm.state.unit_broker.host.split(".")[0], - self.charm.state.unit_broker.host, - socket.getfqdn(), - ] - + self._extra_sans, - } - - @property - def _extra_sans(self) -> list[str]: - """Parse the certificate_extra_sans config option.""" - extra_sans = self.charm.config.certificate_extra_sans or "" - parsed_sans = [] - - if extra_sans == "": - return parsed_sans - - for sans in extra_sans.split(","): - parsed_sans.append(sans.replace("{unit}", self.charm.unit.name.split("/")[1])) - - return parsed_sans + def _request_certificate_renewal(self): + """Generates and submits new CSR to provider.""" + if ( + not self.charm.state.unit_broker.private_key + or not self.charm.state.unit_broker.csr + or not self.charm.state.peer_relation + ): + logger.error("Missing unit private key and/or old csr") + return + + sans = self.charm.broker.tls_manager.build_sans() + new_csr = generate_csr( + private_key=self.charm.state.unit_broker.private_key.encode("utf-8"), + subject=self.charm.state.unit_broker.relation_data.get("private-address", ""), + sans_ip=sans["sans_ip"], + sans_dns=sans["sans_dns"], + ) + + self.certificates.request_certificate_renewal( + old_certificate_signing_request=self.charm.state.unit_broker.csr.encode("utf-8"), + new_certificate_signing_request=new_csr, + ) + + self.charm.state.unit_broker.update({"csr": new_csr.decode("utf-8").strip()}) diff --git a/src/events/upgrade.py b/src/events/upgrade.py index 1e7dfbca..39d50fdc 100644 --- a/src/events/upgrade.py +++ b/src/events/upgrade.py @@ -4,6 +4,7 @@ """Manager for handling Kafka in-place upgrades.""" import logging +import subprocess from typing import TYPE_CHECKING from charms.data_platform_libs.v0.upgrade import ( @@ -17,6 +18,7 @@ from lightkube.core.client import Client from lightkube.core.exceptions import ApiError from lightkube.resources.apps_v1 import StatefulSet +from ops.pebble import ExecError from pydantic import BaseModel from typing_extensions import override @@ -75,6 +77,9 @@ def _on_kafka_pebble_ready_upgrade(self, event: EventBase) -> None: self.set_unit_failed() return + # needed to run before setting config + self.apply_backwards_compatibility_fixes(event) + # required settings given zookeeper connection config has been created self.dependent.config_manager.set_environment() self.dependent.config_manager.set_server_properties() @@ -95,7 +100,7 @@ def _on_kafka_pebble_ready_upgrade(self, event: EventBase) -> None: self.dependent.tls_manager.set_keystore() # start kafka service - self.charm.workload.start(layer=self.dependent._kafka_layer) + self.dependent.workload.start() try: self.post_upgrade_check() @@ -167,3 +172,25 @@ def _set_rolling_update_partition(self, partition: int) -> None: else: cause = str(e) raise KubernetesClientError("Kubernetes StatefulSet patch failed", cause) + + def apply_backwards_compatibility_fixes(self, event) -> None: + """A range of functions needed for backwards compatibility.""" + logger.info("Applying upgrade fixes") + # Rev.38 (VM) - Create credentials for missing internal user, to reconcile state during upgrades + if ( + not self.charm.state.cluster.internal_user_credentials + and self.charm.state.zookeeper.zookeeper_connected + ): + try: + internal_user_credentials = self.dependent.zookeeper._create_internal_credentials() + except (KeyError, RuntimeError, subprocess.CalledProcessError, ExecError) as e: + logger.warning(str(e)) + event.defer() + return + + # only set to relation data when all set + for username, password in internal_user_credentials: + self.charm.state.cluster.update({f"{username}-password": password}) + + # Rev.65 - Creation of external K8s services + self.dependent.update_external_services() diff --git a/src/events/zookeeper.py b/src/events/zookeeper.py index d813dfb2..4b4f5a56 100644 --- a/src/events/zookeeper.py +++ b/src/events/zookeeper.py @@ -36,6 +36,9 @@ def __init__(self, dependent: "BrokerOperator") -> None: self.framework.observe(self.charm.on[ZK].relation_created, self._on_zookeeper_created) self.framework.observe(self.charm.on[ZK].relation_joined, self._on_zookeeper_changed) self.framework.observe(self.charm.on[ZK].relation_changed, self._on_zookeeper_changed) + self.framework.observe( + getattr(self.zookeeper_requires.on, "database_created"), self._on_zookeeper_changed + ) self.framework.observe(self.charm.on[ZK].relation_broken, self._on_zookeeper_broken) def _on_zookeeper_created(self, _) -> None: @@ -86,7 +89,7 @@ def _on_zookeeper_changed(self, event: RelationChangedEvent) -> None: # attempt re-start of Kafka for all units on zookeeper-changed # avoids relying on deferred events elsewhere that may not exist after cluster init - if not self.dependent.healthy and self.charm.state.cluster.internal_user_credentials: + if not self.dependent.healthy: self.charm.on.start.emit() self.charm.on.config_changed.emit() @@ -94,6 +97,7 @@ def _on_zookeeper_changed(self, event: RelationChangedEvent) -> None: def _on_zookeeper_broken(self, _: RelationEvent) -> None: """Handler for `zookeeper_relation_broken` event, ensuring charm blocks.""" self.charm.workload.stop() + self.charm.workload.exec(["rm", self.charm.workload.paths.zk_jaas]) logger.info(f'Broker {self.model.unit.name.split("/")[1]} disconnected') self.charm._set_status(Status.ZK_NOT_RELATED) diff --git a/src/health.py b/src/health.py new file mode 100644 index 00000000..db6c4756 --- /dev/null +++ b/src/health.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Manager for handling Kafka machine health.""" + +import json +import logging +import subprocess +from statistics import mean +from typing import TYPE_CHECKING + +from ops.framework import Object + +from literals import JVM_MEM_MAX_GB, JVM_MEM_MIN_GB + +if TYPE_CHECKING: + from charm import KafkaCharm + from events.broker import BrokerOperator + +logger = logging.getLogger(__name__) + + +class KafkaHealth(Object): + """Manager for handling Kafka machine health.""" + + def __init__(self, dependent: "BrokerOperator") -> None: + super().__init__(dependent, "kafka_health") + self.dependent = dependent + self.charm: "KafkaCharm" = dependent.charm + + if self.charm.substrate == "k8s": + raise Exception("Health object cannot be instantiated on K8s charms.") + + @property + def _service_pid(self) -> int: + """Gets most recent Kafka service pid from the snap logs.""" + return self.dependent.workload.get_service_pid() + + def _get_current_memory_maps(self) -> int: + """Gets the current number of memory maps for the Kafka process.""" + return int( + self.dependent.workload.exec( + ["bash", "-c", f"cat /proc/{self._service_pid}/maps | wc -l"] + ) + ) + + def _get_current_max_files(self) -> int: + """Gets the current file descriptor limit for the Kafka process.""" + return int( + self.dependent.workload.exec( + [ + "bash", + "-c", + rf"cat /proc/{self._service_pid}/limits | grep files | awk '{{print $5}}'", + ] + ) + ) + + def _get_max_memory_maps(self) -> int: + """Gets the current memory map limit for the machine.""" + return int(self.dependent.workload.exec(["sysctl", "-n", "vm.max_map_count"])) + + def _get_vm_swappiness(self) -> int: + """Gets the current vm.swappiness configured for the machine.""" + return int(self.dependent.workload.exec(["sysctl", "-n", "vm.swappiness"])) + + def _get_partitions_size(self) -> tuple[int, int]: + """Gets the number of partitions and their average size from the log dirs.""" + log_dirs_command = [ + "--describe", + f"--bootstrap-server {self.charm.state.bootstrap_server}", + f"--command-config {self.dependent.workload.paths.client_properties}", + ] + try: + log_dirs = self.dependent.workload.run_bin_command( + bin_keyword="log-dirs", + bin_args=log_dirs_command, + opts=[self.dependent.config_manager.tools_log4j_opts], + ) + except subprocess.CalledProcessError: + return (0, 0) + + dirs = {} + for line in log_dirs.splitlines(): + try: + # filters stdout to only relevant lines + dirs = json.loads(line) + break + except json.decoder.JSONDecodeError: + continue + + if not dirs: + return (0, 0) + + partitions = [] + sizes = [] + for broker in dirs["brokers"]: + for log_dir in broker["logDirs"]: + for partition in log_dir["partitions"]: + partitions.append(partition["partition"]) + sizes.append(int(partition["size"])) + + if not sizes or not partitions: + return (0, 0) + + average_partition_size = mean(sizes) + total_partitions = len(partitions) + + return (total_partitions, average_partition_size) + + def _check_memory_maps(self) -> bool: + """Checks that the number of used memory maps is not approaching threshold.""" + max_maps = self._get_max_memory_maps() + current_maps = self._get_current_memory_maps() + + # eyeballing warning if 80% used, can be changed + if max_maps * 0.8 <= current_maps: + logger.warning( + f"number of Kafka memory maps {current_maps} is approaching limit of {max_maps} - increase /etc/sysctl.conf vm.max_map_count limit and restart machine" + ) + return False + + return True + + def _check_file_descriptors(self) -> bool: + """Checks that the number of used file descriptors is not approaching threshold.""" + if not self.dependent.config_manager.client_listeners: + return True + + total_partitions, average_partition_size = self._get_partitions_size() + segment_size = self.charm.config.log_segment_bytes + + minimum_fd_limit = total_partitions * (average_partition_size / segment_size) + current_max_files = self._get_current_max_files() + + # eyeballing warning if 80% used, can be changed + if current_max_files * 0.8 <= minimum_fd_limit: + logger.warning( + f"number of required Kafka file descriptors {minimum_fd_limit} is approaching limit of {current_max_files} - increase /etc/security/limits.d/root.conf limit and restart machine" + ) + return False + + return True + + def _check_vm_swappiness(self) -> bool: + """Checks that vm.swappiness is configured correctly on the machine.""" + vm_swappiness = self._get_vm_swappiness() + + if vm_swappiness > 1: + logger.error( + f"machine vm.swappiness setting of {vm_swappiness} is higher than 1 - set /etc/syscl.conf vm.swappiness=1 and restart machine" + ) + return False + + return True + + def _check_total_memory(self) -> bool: + """Checks that the total available memory is sufficient for desired profile.""" + if not (meminfo := self.dependent.workload.read(path="/proc/meminfo")): + return False + + total_memory_gb = int(meminfo[0].split()[1]) / 1000000 + target_memory_gb = ( + JVM_MEM_MIN_GB if self.charm.config.profile == "testing" else JVM_MEM_MAX_GB + ) + + # TODO: with memory barely above JVM heap, there will be no room for OS page cache, degrading perf + # need to figure out a better way of ensuring sufficiently beefy machines + if target_memory_gb >= total_memory_gb: + logger.error( + f"Insufficient total memory '{round(total_memory_gb, 2)}' for desired performance profile '{self.charm.config.profile}' - redeploy with greater than {target_memory_gb}GB available memory" + ) + return False + + return True + + def machine_configured(self) -> bool: + """Checks machine configuration for healthy settings. + + Returns: + True if settings safely configured. Otherwise False + """ + if not all( + [ + self._check_total_memory(), + self._check_memory_maps(), + self._check_file_descriptors(), + self._check_vm_swappiness(), + ] + ): + return False + + return True diff --git a/src/literals.py b/src/literals.py index 5323719b..c0794721 100644 --- a/src/literals.py +++ b/src/literals.py @@ -2,15 +2,16 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -"""Collection of globals common to the Kafka K8s Charm.""" +"""Collection of globals common to the KafkaCharm.""" from dataclasses import dataclass from enum import Enum -from typing import Literal +from typing import Literal, NamedTuple from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, StatusBase, WaitingStatus CHARM_KEY = "kafka-k8s" +SNAP_NAME = "charmed-kafka" CONTAINER = "kafka" SUBSTRATE = "k8s" STORAGE = "data" @@ -57,9 +58,29 @@ METRICS_RULES_DIR = "./src/alert_rules/prometheus" LOGS_RULES_DIR = "./src/alert_rules/loki" + +@dataclass +class Ports: + """Types of ports for a Kafka broker.""" + + client: int + internal: int + external: int + + AuthProtocol = Literal["SASL_PLAINTEXT", "SASL_SSL", "SSL"] AuthMechanism = Literal["SCRAM-SHA-512", "OAUTHBEARER", "SSL"] -Scope = Literal["INTERNAL", "CLIENT"] +Scope = Literal["INTERNAL", "CLIENT", "EXTERNAL"] +AuthMap = NamedTuple("AuthMap", protocol=AuthProtocol, mechanism=AuthMechanism) + +SECURITY_PROTOCOL_PORTS: dict[AuthMap, Ports] = { + AuthMap("SASL_PLAINTEXT", "SCRAM-SHA-512"): Ports(9092, 19092, 29092), + AuthMap("SASL_SSL", "SCRAM-SHA-512"): Ports(9093, 19093, 29093), + AuthMap("SSL", "SSL"): Ports(9094, 19094, 29094), + AuthMap("SASL_PLAINTEXT", "OAUTHBEARER"): Ports(9095, 19095, 29095), + AuthMap("SASL_SSL", "OAUTHBEARER"): Ports(9096, 19096, 29096), +} + DebugLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"] DatabagScope = Literal["unit", "app"] Substrates = Literal["vm", "k8s"] @@ -90,23 +111,6 @@ } -@dataclass -class Ports: - """Types of ports for a Kafka broker.""" - - client: int - internal: int - - -SECURITY_PROTOCOL_PORTS: dict[tuple[AuthProtocol, AuthMechanism], Ports] = { - ("SASL_PLAINTEXT", "SCRAM-SHA-512"): Ports(9092, 19092), - ("SASL_PLAINTEXT", "OAUTHBEARER"): Ports(9095, 19095), - ("SASL_SSL", "SCRAM-SHA-512"): Ports(9093, 19093), - ("SASL_SSL", "OAUTHBEARER"): Ports(9096, 19096), - ("SSL", "SSL"): Ports(9094, 19094), -} - - @dataclass class Role: value: str @@ -194,6 +198,7 @@ class Status(Enum): NO_PEER_CLUSTER_RELATION = StatusLevel( BlockedStatus("missing required peer-cluster relation"), "DEBUG" ) + SNAP_NOT_INSTALLED = StatusLevel(BlockedStatus(f"unable to install {SNAP_NAME} snap"), "ERROR") BROKER_NOT_RUNNING = StatusLevel(BlockedStatus("Broker not running"), "WARNING") NOT_ALL_RELATED = StatusLevel(MaintenanceStatus("not all units related"), "DEBUG") CC_NOT_RUNNING = StatusLevel(BlockedStatus("Cruise Control not running"), "WARNING") @@ -221,6 +226,14 @@ class Status(Enum): WaitingStatus("internal broker credentials not yet added"), "DEBUG" ) NO_CERT = StatusLevel(WaitingStatus("unit waiting for signed certificates"), "INFO") + SYSCONF_NOT_OPTIMAL = StatusLevel( + ActiveStatus("machine system settings are not optimal - see logs for info"), + "WARNING", + ) + SYSCONF_NOT_POSSIBLE = StatusLevel( + BlockedStatus("sysctl params cannot be set. Is the machine running on a container?"), + "WARNING", + ) NOT_IMPLEMENTED = StatusLevel( BlockedStatus("feature not yet implemented"), "WARNING", diff --git a/src/managers/config.py b/src/managers/config.py index a2f05bf0..c272e562 100644 --- a/src/managers/config.py +++ b/src/managers/config.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. +# Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. """Manager for handling Kafka configuration.""" @@ -13,6 +13,7 @@ from abc import abstractmethod from typing import Iterable +from lightkube.core.exceptions import ApiError from typing_extensions import override from core.cluster import ClusterState @@ -28,8 +29,7 @@ JVM_MEM_MAX_GB, JVM_MEM_MIN_GB, SECURITY_PROTOCOL_PORTS, - AuthMechanism, - AuthProtocol, + AuthMap, Scope, ) @@ -56,16 +56,21 @@ class Listener: """Definition of a listener. Args: + auth_map: AuthMap representing the auth.protocol and auth.mechanism for the listener + scope: scope of the listener, CLIENT, INTERNAL or EXTERNAL host: string with the host that will be announced - protocol: auth protocol to be used - scope: scope of the listener, CLIENT or INTERNAL + node_port (optional): the node-port for the listener if scope=EXTERNAL """ - def __init__(self, host: str, protocol: AuthProtocol, mechanism: AuthMechanism, scope: Scope): - self.protocol: AuthProtocol = protocol - self.mechanism: AuthMechanism = mechanism + def __init__( + self, auth_map: AuthMap, scope: Scope, host: str = "", node_port: int | None = None + ): + self.auth_map = auth_map + self.protocol = auth_map.protocol + self.mechanism = auth_map.mechanism self.host = host self.scope = scope + self.node_port = node_port @property def scope(self) -> Scope: @@ -75,8 +80,8 @@ def scope(self) -> Scope: @scope.setter def scope(self, value): """Internal scope validator.""" - if value not in ["CLIENT", "INTERNAL"]: - raise ValueError("Only CLIENT and INTERNAL scopes are accepted") + if value not in ["CLIENT", "INTERNAL", "EXTERNAL"]: + raise ValueError("Only CLIENT, INTERNAL and EXTERNAL scopes are accepted") self._scope = value @@ -84,15 +89,10 @@ def scope(self, value): def port(self) -> int: """Port associated with the protocol/scope. - Defaults to internal port. - Returns: Integer of port number """ - port = SECURITY_PROTOCOL_PORTS[self.protocol, self.mechanism] - if self.scope == "CLIENT": - return port.client - return port.internal + return getattr(SECURITY_PROTOCOL_PORTS[self.auth_map], self.scope.lower()) @property def name(self) -> str: @@ -106,12 +106,15 @@ def protocol_map(self) -> str: @property def listener(self) -> str: - """Return `name://:port`.""" - return f"{self.name}://:{self.port}" + """Return `name://0.0.0.0:port`.""" + return f"{self.name}://0.0.0.0:{self.port}" @property def advertised_listener(self) -> str: """Return `name://host:port`.""" + if self.scope == "EXTERNAL": + return f"{self.name}://{self.host}:{self.node_port}" + return f"{self.name}://{self.host}:{self.port}" @@ -230,16 +233,6 @@ def heap_opts(self) -> str: return f"KAFKA_HEAP_OPTS='{' '.join(opts)}'" - @property - def security_protocol(self) -> AuthProtocol: - """Infers current charm security.protocol based on current relations.""" - # FIXME: When we have multiple auth_mechanims/listeners, remove this method - return ( - "SASL_SSL" - if (self.state.cluster.tls_enabled and self.state.unit_broker.certificate) - else "SASL_PLAINTEXT" - ) - class ConfigManager(CommonConfigManager): """Manager for handling Kafka configuration.""" @@ -355,8 +348,7 @@ def scram_properties(self) -> list[str]: f'listener.name.{listener_name}.{listener_mechanism}.sasl.jaas.config=org.apache.kafka.common.security.scram.ScramLoginModule required username="{username}" password="{password}";', f"listener.name.{listener_name}.sasl.enabled.mechanisms={self.internal_listener.mechanism}", ] - - for auth in self.client_listeners: + for auth in self.client_listeners + self.external_listeners: if not auth.mechanism.startswith("SCRAM"): continue @@ -399,7 +391,7 @@ def oauth_properties(self) -> list[str]: if not self.state.oauth.uses_trusted_ca: truststore_cfg = f'oauth.ssl.truststore.location="{self.workload.paths.truststore}" oauth.ssl.truststore.password="{self.state.unit_broker.truststore_password}" oauth.ssl.truststore.type="JKS"' - scram_properties = [ + oauth_properties = [ textwrap.dedent( f"""\ listener.name.{listener.name.lower()}.{listener.mechanism.lower()}.sasl.jaas.config=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required \\ @@ -419,58 +411,64 @@ def oauth_properties(self) -> list[str]: "principal.builder.class=io.strimzi.kafka.oauth.server.OAuthKafkaPrincipalBuilder", ] - return scram_properties - - @property - def security_protocol(self) -> AuthProtocol: - """Infers current charm security.protocol based on current relations.""" - return ( - "SASL_SSL" - if (self.state.cluster.tls_enabled and self.state.unit_broker.certificate) - else "SASL_PLAINTEXT" - ) + return oauth_properties @property def internal_listener(self) -> Listener: """Return the internal listener.""" - protocol = self.security_protocol - mechanism: AuthMechanism = "SCRAM-SHA-512" return Listener( - host=self.state.unit_broker.host, - protocol=protocol, - mechanism=mechanism, + host=self.state.unit_broker.internal_address, + auth_map=self.state.default_auth, scope="INTERNAL", ) @property def client_listeners(self) -> list[Listener]: """Return a list of extra listeners.""" - protocol_mechanism_dict: list[tuple[AuthProtocol, AuthMechanism]] = [] - - related_clients = bool(self.state.client_relations) - balancer_involved = self.state.runs_balancer or self.state.peer_cluster_relation - - if related_clients or balancer_involved: - protocol_mechanism_dict.append((self.security_protocol, "SCRAM-SHA-512")) - if self.state.oauth_relation: - protocol_mechanism_dict.append((self.security_protocol, "OAUTHBEARER")) - if self.state.cluster.mtls_enabled: - protocol_mechanism_dict.append(("SSL", "SSL")) - return [ Listener( - host=self.state.unit_broker.host, - protocol=protocol, - mechanism=mechanism, - scope="CLIENT", + host=self.state.unit_broker.internal_address, auth_map=auth_map, scope="CLIENT" ) - for protocol, mechanism in protocol_mechanism_dict + for auth_map in self.state.enabled_auth ] + @property + def external_listeners(self) -> list[Listener]: + """Return a list of extra listeners.""" + if not self.config.expose_external: + return [] + + listeners = [] + for auth in self.state.enabled_auth: + node_port = 0 + try: + node_port = self.state.unit_broker.k8s.get_listener_nodeport(auth) + except ApiError as e: + # don't worry about defining a service during cluster init + # as it doesn't exist yet to `kubectl get` + logger.debug(e) + continue + + if not node_port: + continue + + listeners.append( + Listener( + auth_map=auth, + scope="EXTERNAL", + host=self.state.unit_broker.host, + # default in case service not created yet during cluster init + # will resolve during config-changed + node_port=node_port, + ) + ) + + return listeners + @property def all_listeners(self) -> list[Listener]: """Return a list with all expected listeners.""" - return [self.internal_listener] + self.client_listeners + return [self.internal_listener] + self.client_listeners + self.external_listeners @property def inter_broker_protocol_version(self) -> str: @@ -525,8 +523,8 @@ def _build_internal_client_properties( properties = [ f'sasl.jaas.config=org.apache.kafka.common.security.scram.ScramLoginModule required username="{username}" password="{password}";', - "sasl.mechanism=SCRAM-SHA-512", - f"security.protocol={self.security_protocol}", + f"sasl.mechanism={self.state.default_auth.mechanism}", + f"security.protocol={self.state.default_auth.protocol}", f"bootstrap.servers={self.state.bootstrap_server}", ] @@ -631,7 +629,6 @@ def set_environment(self) -> None: updated_env_list = [ self.kafka_opts, self.kafka_jmx_opts, - self.cc_jmx_opts, self.jvm_performance_opts, self.heap_opts, self.log_level, @@ -770,8 +767,8 @@ def cruise_control_properties(self) -> list[str]: f"zookeeper.connect={self.state.balancer.zk_uris}", "zookeeper.security.enabled=true", f'sasl.jaas.config=org.apache.kafka.common.security.scram.ScramLoginModule required username="{self.state.balancer.broker_username}" password="{self.state.balancer.broker_password}";', - "sasl.mechanism=SCRAM-SHA-512", - f"security.protocol={self.security_protocol}", + f"sasl.mechanism={self.state.default_auth.mechanism}", + f"security.protocol={self.state.default_auth.protocol}", f"capacity.config.file={self.workload.paths.capacity_jbod_json}", "webserver.security.enable=true", f"webserver.auth.credentials.file={self.workload.paths.cruise_control_auth}", diff --git a/src/managers/k8s.py b/src/managers/k8s.py new file mode 100644 index 00000000..8866e799 --- /dev/null +++ b/src/managers/k8s.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Manager for handling Kafka Kubernetes resources for a single Kafka pod.""" + +import logging +from functools import cached_property + +from lightkube.core.client import Client +from lightkube.core.exceptions import ApiError +from lightkube.models.core_v1 import ServicePort, ServiceSpec +from lightkube.models.meta_v1 import ObjectMeta, OwnerReference +from lightkube.resources.core_v1 import Node, Pod, Service + +from literals import SECURITY_PROTOCOL_PORTS, AuthMap, AuthMechanism + +logger = logging.getLogger(__name__) + +# default logging from lightkube httpx requests is very noisy +logging.getLogger("lightkube").disabled = True +logging.getLogger("lightkube.core.client").disabled = True +logging.getLogger("httpx").disabled = True +logging.getLogger("httpcore").disabled = True + + +class K8sManager: + """Manager for handling Kafka Kubernetes resources for a single Kafka pod.""" + + def __init__( + self, + pod_name: str, + namespace: str, + ): + self.pod_name = pod_name + self.app_name = "-".join(pod_name.split("-")[:-1]) + self.namespace = namespace + self.bootstrap_service_name = f"{self.app_name}-bootstrap" + self.short_auth_mechanism_mapping: dict[AuthMechanism, str] = { + "SCRAM-SHA-512": "scram", + "OAUTHBEARER": "oauth", + "SSL": "ssl", + } + + @cached_property + def client(self) -> Client: + """The Lightkube client.""" + return Client( # pyright: ignore[reportArgumentType] + field_manager=self.pod_name, + namespace=self.namespace, + ) + + # --- GETTERS --- + + def get_pod(self, pod_name: str = "") -> Pod: + """Gets the Pod via the K8s API.""" + # Allows us to get pods from other peer units + pod_name = pod_name or self.pod_name + + return self.client.get( + res=Pod, + name=self.pod_name, + ) + + def get_node(self, pod: Pod) -> Node: + """Gets the Node the Pod is running on via the K8s API.""" + if not pod.spec or not pod.spec.nodeName: + raise Exception("Could not find podSpec or nodeName") + + return self.client.get( + Node, + name=pod.spec.nodeName, + ) + + def get_node_ip(self, node: Node) -> str: + """Gets the IP Address of the Node via the K8s API.""" + # all these redundant checks are because Lightkube's typing is awful + if not node.status or not node.status.addresses: + raise Exception(f"No status found for {node}") + + for addresses in node.status.addresses: + if addresses.type in ["ExternalIP", "InternalIP", "Hostname"]: + return addresses.address + + return "" + + def get_service(self, service_name: str) -> Service | None: + """Gets the Service via the K8s API.""" + return self.client.get( + res=Service, + name=service_name, + ) + + def get_node_port( + self, + service: Service, + auth_map: AuthMap, + ) -> int: + """Gets the NodePort number for the service via the K8s API.""" + if not service.spec or not service.spec.ports: + raise Exception("Could not find Service spec or ports") + + for port in service.spec.ports: + if ( + auth_map.protocol.lower().replace("_", "-") in port.name + and self.short_auth_mechanism_mapping[auth_map.mechanism] in port.name + ): + return port.nodePort + + raise Exception( + f"Unable to find NodePort using {auth_map.protocol} and {auth_map.mechanism} for the {service} service" + ) + + def build_listener_service_name(self, auth_map: AuthMap): + """Builds the Service name for a given auth.protocol and auth.mechanism. + + Returns: + String of listener service name + e.g `kafka-0-sasl-plaintext-scram`, `kafka-12-sasl-ssl-oauth` + """ + return f"{self.pod_name}-{auth_map.protocol.lower().replace('_','-')}-{self.short_auth_mechanism_mapping[auth_map.mechanism]}" + + def get_listener_nodeport(self, auth_map: AuthMap) -> int: + """Gets the current NodePort for the desired auth.protocol and auth.mechanism service.""" + service_name = self.build_listener_service_name(auth_map) + if not (service := self.get_service(service_name)): + raise Exception( + f"Unable to find Service using {auth_map.protocol} and {auth_map.mechanism}" + ) + + return self.get_node_port(service, auth_map) + + def get_bootstrap_nodeport(self, auth_map: AuthMap) -> int: + """Gets the current NodePort for the desired bootstrap auth.protocol and auth.mechanism service.""" + if not (service := self.get_service(self.bootstrap_service_name)): + raise Exception("Unable to find bootstrap Service") + + return self.get_node_port(service, auth_map) + + def build_bootstrap_services(self) -> Service: + """Builds a ClusterIP service for initial client connection.""" + pod = self.get_pod(pod_name=self.pod_name) + if not pod.metadata: + raise Exception(f"Could not find metadata for {pod}") + + ports = [] + for (auth_protocol, auth_mechanism), port in SECURITY_PROTOCOL_PORTS.items(): + ports.append( + ServicePort( + protocol="TCP", + port=port.external, + targetPort=port.external, + name=f"{auth_protocol.lower().replace('_', '-')}-{self.short_auth_mechanism_mapping[auth_mechanism]}-bootstrap-port", + ) + ) + + return Service( + metadata=ObjectMeta( + name=self.bootstrap_service_name, + namespace=self.namespace, + # owned by the StatefulSet + ownerReferences=pod.metadata.ownerReferences, + ), + spec=ServiceSpec( + externalTrafficPolicy="Local", + type="NodePort", + selector={"app.kubernetes.io/name": self.app_name}, + ports=ports, + ), + ) + + def build_listener_service(self, auth_map: AuthMap) -> Service: + """Builds a NodePort service for individual brokers and auth.protocols + auth.mechanisms. + + In order to discover all Kafka brokers, a client application must know the location of at least 1 + active broker, `bootstrap-server`. From there, the broker returns the `advertised.listeners` + to the client application, here specified as :. + + K8s-external requests hit :, and are redirected to the corresponding + statefulset.kubernetes.io/pod-name from the selector, and port matching the auth mechanism. + + If a pod was rescheduled to a new node, the node-ip defined in the `advertised.listeners` + will be updated during the normal charm `config-changed` reconciliation. + """ + pod = self.get_pod(pod_name=self.pod_name) + if not pod.metadata: + raise Exception(f"Could not find metadata for {pod}") + + service_name = self.build_listener_service_name(auth_map) + svc_port = SECURITY_PROTOCOL_PORTS[auth_map].external + + return Service( + metadata=ObjectMeta( + name=service_name, + namespace=self.namespace, + ownerReferences=[ + OwnerReference( + apiVersion=pod.apiVersion, + kind=pod.kind, + name=self.pod_name, + uid=pod.metadata.uid, + blockOwnerDeletion=False, + ) + ], + ), + spec=ServiceSpec( + externalTrafficPolicy="Local", + type="NodePort", + selector={"statefulset.kubernetes.io/pod-name": self.pod_name}, + ports=[ + ServicePort( + protocol="TCP", + port=svc_port, + targetPort=svc_port, + name=f"{service_name}-port", + ), + ], + ), + ) + + def apply_service(self, service: Service) -> None: + """Applies a given Service.""" + try: + self.client.apply(service) + except ApiError as e: + if e.status.code == 403: + logger.error("Could not apply service, application needs `juju trust`") + return + if e.status.code == 422 and "port is already allocated" in e.status.message: + logger.error(e.status.message) + return + else: + raise diff --git a/src/managers/tls.py b/src/managers/tls.py index 42cabc9a..b6b64efa 100644 --- a/src/managers/tls.py +++ b/src/managers/tls.py @@ -5,24 +5,36 @@ """Manager for handling Kafka TLS configuration.""" import logging -import subprocess # nosec B404 +import socket +import subprocess +from typing import TypedDict # nosec B404 from ops.pebble import ExecError from core.cluster import ClusterState +from core.structured_config import CharmConfig from core.workload import WorkloadBase from literals import GROUP, USER, Substrates logger = logging.getLogger(__name__) +Sans = TypedDict("Sans", {"sans_ip": list[str], "sans_dns": list[str]}) + class TLSManager: """Manager for building necessary files for Java TLS auth.""" - def __init__(self, state: ClusterState, workload: WorkloadBase, substrate: Substrates): + def __init__( + self, + state: ClusterState, + workload: WorkloadBase, + substrate: Substrates, + config: CharmConfig, + ): self.state = state self.workload = workload self.substrate = substrate + self.config = config self.keytool = "charmed-kafka.keytool" if self.substrate == "vm" else "keytool" @@ -115,6 +127,78 @@ def remove_cert(self, alias: str) -> None: logger.error(e.stdout) raise e + def _build_extra_sans(self) -> list[str]: + """Parse the certificate_extra_sans config option.""" + extra_sans = self.config.certificate_extra_sans or "" + parsed_sans = [] + + if extra_sans == "": + return parsed_sans + + for sans in extra_sans.split(","): + parsed_sans.append(sans.replace("{unit}", str(self.state.unit_broker.unit_id))) + + return parsed_sans + + def build_sans(self) -> Sans: + """Builds a SAN dict of DNS names and IPs for the unit.""" + if self.substrate == "vm": + return { + "sans_ip": [ + self.state.unit_broker.host, + ], + "sans_dns": [self.state.unit_broker.unit.name, socket.getfqdn()] + + self._build_extra_sans(), + } + else: + return { + "sans_ip": sorted( + [ + str(self.state.bind_address), + self.state.unit_broker.node_ip, + ] + ), + "sans_dns": sorted( + [ + self.state.unit_broker.internal_address.split(".")[0], + self.state.unit_broker.internal_address, + socket.getfqdn(), + ] + + self._build_extra_sans() + ), + } + + def get_current_sans(self) -> Sans | None: + """Gets the current SANs for the unit cert.""" + if not self.state.unit_broker.certificate: + return + + command = ["openssl", "x509", "-noout", "-ext", "subjectAltName", "-in", "server.pem"] + + try: + sans_lines = self.workload.exec( + command=command, working_dir=self.workload.paths.conf_path + ).splitlines() + except (subprocess.CalledProcessError, ExecError) as e: + logger.error(e.stdout) + raise e + + for line in sans_lines: + if "DNS" in line and "IP" in line: + break + + sans_ip = [] + sans_dns = [] + for item in line.split(", "): + san_type, san_value = item.split(":") + + if san_type == "DNS": + sans_dns.append(san_value) + if san_type == "IP Address": + sans_ip.append(san_value) + + return {"sans_ip": sorted(sans_ip), "sans_dns": sorted(sans_dns)} + def remove_stores(self) -> None: """Cleans up all keys/certs/stores on a unit.""" try: diff --git a/src/workload.py b/src/workload.py index 83d279f4..80dced28 100644 --- a/src/workload.py +++ b/src/workload.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. +# Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. """KafkaSnap class and methods.""" @@ -7,12 +7,12 @@ import logging import re -from ops import Container -from ops.pebble import ExecError, Layer +from ops import Container, pebble +from ops.pebble import ExecError from typing_extensions import override from core.workload import CharmedKafkaPaths, WorkloadBase -from literals import BALANCER, BROKER, CHARM_KEY +from literals import BALANCER, BROKER, CHARM_KEY, GROUP, JMX_EXPORTER_PORT, USER logger = logging.getLogger(__name__) @@ -23,12 +23,20 @@ class Workload(WorkloadBase): paths: CharmedKafkaPaths service: str - def __init__(self, container: Container) -> None: + def __init__(self, container: Container | None) -> None: + if not container: + raise AttributeError("Container is required.") + self.container = container + @property + @override + def container_can_connect(self) -> bool: + return self.container.can_connect() + @override - def start(self, layer: Layer) -> None: - self.container.add_layer(CHARM_KEY, layer, combine=True) + def start(self) -> None: + self.container.add_layer(CHARM_KEY, self.layer, combine=True) self.container.restart(self.service) @override @@ -109,26 +117,31 @@ def run_bin_command( # ------- Kafka vm specific ------- def install(self) -> None: - """Loads the Kafka snap from LP.""" + """Loads the Kafka snap from LP. + + Returns: + True if successfully installed. False otherwise. + """ raise NotImplementedError - @override - def get_version(self) -> str: - if not self.container.can_connect(): - return "" + def get_service_pid(self) -> int: + """Gets pid of a currently active snap service. - try: - version = re.split(r"[\s\-]", self.run_bin_command("topics", ["--version"]))[0] - except: # noqa: E722 - version = "" - return version + Returns: + Integer of pid + + Raises: + SnapError if error occurs or if no pid string found in most recent log + """ + raise NotImplementedError class KafkaWorkload(Workload): """Broker specific wrapper.""" - def __init__(self, container: Container) -> None: + def __init__(self, container: Container | None) -> None: super().__init__(container) + self.paths = CharmedKafkaPaths(BROKER) self.service = BROKER.service @@ -142,11 +155,45 @@ def get_version(self) -> str: version = "" return version + @property + @override + def layer(self) -> pebble.Layer: + """Returns a Pebble configuration layer for Kafka.""" + extra_opts = [ + f"-javaagent:{self.paths.jmx_prometheus_javaagent}={JMX_EXPORTER_PORT}:{self.paths.jmx_prometheus_config}", + f"-Djava.security.auth.login.config={self.paths.zk_jaas}", + ] + command = ( + f"{self.paths.binaries_path}/bin/kafka-server-start.sh {self.paths.server_properties}" + ) + + layer_config: pebble.LayerDict = { + "summary": "kafka layer", + "description": "Pebble config layer for kafka", + "services": { + BROKER.service: { + "override": "merge", + "summary": "kafka", + "command": command, + "startup": "enabled", + "user": str(USER), + "group": GROUP, + "environment": { + "KAFKA_OPTS": " ".join(extra_opts), + # FIXME https://github.com/canonical/kafka-k8s-operator/issues/80 + "JAVA_HOME": "/usr/lib/jvm/java-18-openjdk-amd64", + "LOG_DIR": self.paths.logs_path, + }, + } + }, + } + return pebble.Layer(layer_config) + class BalancerWorkload(Workload): """Balancer specific wrapper.""" - def __init__(self, container: Container) -> None: + def __init__(self, container: Container | None) -> None: super().__init__(container) self.paths = CharmedKafkaPaths(BALANCER) self.service = BALANCER.service @@ -180,3 +227,36 @@ def run_bin_command( @override def get_version(self) -> str: raise NotImplementedError + + @property + @override + def layer(self) -> pebble.Layer: + """Returns a Pebble configuration layer for CruiseControl.""" + extra_opts = [ + # FIXME: Port already in use by the broker. To be fixed once we have CC_JMX_OPTS + # f"-javaagent:{CharmedKafkaPaths(BROKER).jmx_prometheus_javaagent}={JMX_EXPORTER_PORT}:{CharmedKafkaPaths(BROKER).jmx_prometheus_config}", + f"-Djava.security.auth.login.config={self.paths.balancer_jaas}", + ] + command = f"{self.paths.binaries_path}/bin/kafka-cruise-control-start.sh {self.paths.cruise_control_properties}" + + layer_config: pebble.LayerDict = { + "summary": "kafka layer", + "description": "Pebble config layer for kafka", + "services": { + BALANCER.service: { + "override": "merge", + "summary": "balancer", + "command": command, + "startup": "enabled", + "user": str(USER), + "group": GROUP, + "environment": { + "KAFKA_OPTS": " ".join(extra_opts), + # FIXME https://github.com/canonical/kafka-k8s-operator/issues/80 + "JAVA_HOME": "/usr/lib/jvm/java-18-openjdk-amd64", + "LOG_DIR": self.paths.logs_path, + }, + } + }, + } + return pebble.Layer(layer_config) diff --git a/tests/integration/ha/test_ha.py b/tests/integration/ha/test_ha.py index da0f9aaf..8d44f19c 100644 --- a/tests/integration/ha/test_ha.py +++ b/tests/integration/ha/test_ha.py @@ -84,9 +84,10 @@ async def test_build_and_deploy(ops_test: OpsTest, kafka_charm, app_charm): application_name=APP_NAME, num_units=1, resources={"kafka-image": KAFKA_CONTAINER}, + trust=True, ), - ops_test.model.deploy(ZK_NAME, channel="3/edge", num_units=1), - ops_test.model.deploy(app_charm, application_name=DUMMY_NAME), + ops_test.model.deploy(ZK_NAME, channel="3/edge", num_units=1, trust=True), + ops_test.model.deploy(app_charm, application_name=DUMMY_NAME, trust=True), ) await ops_test.model.wait_for_idle(apps=[APP_NAME, ZK_NAME], timeout=2000) @@ -121,8 +122,11 @@ async def test_multi_cluster_isolation(ops_test: OpsTest, kafka_charm): application_name=second_kafka_name, num_units=1, resources={"kafka-image": KAFKA_CONTAINER}, + trust=True, + ), + ops_test.model.deploy( + ZK_NAME, application_name=second_zk_name, channel="3/edge", trust=True ), - ops_test.model.deploy(ZK_NAME, application_name=second_zk_name, channel="3/edge"), ) await ops_test.model.add_relation(second_kafka_name, second_zk_name) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index dab2b024..fa16f969 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -4,9 +4,8 @@ import json import logging -import socket +import re import subprocess -from contextlib import closing from pathlib import Path from subprocess import PIPE, CalledProcessError, check_output from typing import Any, Dict, List, Optional, Set @@ -134,9 +133,18 @@ def extract_ca(ops_test: OpsTest, unit_name: str) -> str | None: return user_secret.get("ca-cert") or user_secret.get("ca") -def check_socket(host: str, port: int) -> bool: - with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: - return sock.connect_ex((host, port)) == 0 +def netcat(host: str, port: int) -> bool: + try: + check_output( + f"nc -zv {host} {port}", + stderr=PIPE, + shell=True, + universal_newlines=True, + ) + return True + except CalledProcessError as e: + logger.error(e) + return False def check_tls(ip: str, port: int) -> bool: @@ -558,6 +566,24 @@ def balancer_is_secure(ops_test: OpsTest, app_name: str) -> bool: return all((unauthorized_ok, authorized_ok)) +def get_node_port(ops_test: OpsTest, app_name: str, service_name: str): + namespace = ops_test.model.info.name + bootstrap_service = check_output( + f"kubectl describe svc -n {namespace} {app_name}-bootstrap", + stderr=PIPE, + shell=True, + universal_newlines=True, + ) + + bootstrap_node_port = 0 + for line in bootstrap_service.splitlines(): + logger.error(f"{line}") + if "NodePort" in line and service_name in line: + bootstrap_node_port = int(line.split()[-1].split("/")[0]) + + return bootstrap_node_port + + @retry( wait=wait_fixed(20), # long enough to not overwhelm the API stop=stop_after_attempt(180), # give it 60 minutes to load @@ -620,6 +646,64 @@ def get_kafka_broker_state(ops_test: OpsTest, app_name: str) -> JSON: return broker_state_json +def check_external_access_non_tls(ops_test: OpsTest, unit_name: str): + try: + node_ip = check_output( + "kubectl get nodes -o wide | awk -v OFS='\t\t' '{print $6}' | sed 1D", + stderr=PIPE, + shell=True, + universal_newlines=True, + ).strip() + + # grabbing the helpful client.properties for later + client_properties = check_output( + f"JUJU_MODEL={ops_test.model_full_name} juju ssh --container kafka {unit_name} 'cat /etc/kafka/client.properties'", + stderr=PIPE, + shell=True, + universal_newlines=True, + ).splitlines() + + bootstrap_node_port = get_node_port( + ops_test, unit_name.split("/")[0], "sasl-plaintext-scram-bootstrap-port" + ) + + except CalledProcessError as e: + logger.error(vars(e)) + raise e + + admin_password = "" + for line in client_properties: + if match := re.search(r"password\=\"(.*)\"", line): + admin_password = match.group(1) + break + + admin_username = "admin" + bootstrap_server = f"{node_ip}:{bootstrap_node_port}" + + client = KafkaClient( + servers=[bootstrap_server], + username=admin_username, + password=admin_password, + security_protocol="SASL_PLAINTEXT", + ) + + topic_config = NewTopic( + name="HOT-TOPIC", + num_partitions=100, + replication_factor=1, + ) + client.create_topic(topic=topic_config) + + topics_list = check_output( + f"JUJU_MODEL={ops_test.model_full_name} juju ssh --container kafka {unit_name} '/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --command-config /etc/kafka/client.properties --list'", + stderr=PIPE, + shell=True, + universal_newlines=True, + ) + + assert "HOT-TOPIC" in topics_list + + def get_replica_count_by_broker_id(ops_test: OpsTest, app_name: str) -> dict[str, Any]: broker_state_json = get_kafka_broker_state(ops_test, app_name) return broker_state_json.get("ReplicaCountByBrokerId", {}) diff --git a/tests/integration/test_balancer.py b/tests/integration/test_balancer.py index e3e2ea17..280ee40c 100644 --- a/tests/integration/test_balancer.py +++ b/tests/integration/test_balancer.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. +# Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. import asyncio diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 8f4f426c..a8f270b3 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -17,10 +17,11 @@ KAFKA_CONTAINER, REL_NAME_ADMIN, ZK_NAME, + check_external_access_non_tls, check_logs, - check_socket, count_lines_with, get_address, + netcat, run_client_properties, ) @@ -36,12 +37,14 @@ async def test_build_and_deploy(ops_test: OpsTest): channel="3/edge", application_name=ZK_NAME, num_units=3, + trust=True, ), ops_test.model.deploy( kafka_charm, application_name=APP_NAME, num_units=1, resources={"kafka-image": KAFKA_CONTAINER}, + trust=True, ), ) await ops_test.model.block_until(lambda: len(ops_test.model.applications[ZK_NAME].units) == 3) @@ -86,29 +89,29 @@ async def test_remove_zk_relation_relate(ops_test: OpsTest): @pytest.mark.abort_on_fail async def test_listeners(ops_test: OpsTest, app_charm): address = await get_address(ops_test=ops_test) - assert check_socket( + assert netcat( address, SECURITY_PROTOCOL_PORTS["SASL_PLAINTEXT", "SCRAM-SHA-512"].internal ) # Internal listener # Client listener should not be enable if there is no relations - assert not check_socket( - address, SECURITY_PROTOCOL_PORTS["SASL_PLAINTEXT", "SCRAM-SHA-512"].client - ) + assert not netcat(address, SECURITY_PROTOCOL_PORTS["SASL_PLAINTEXT", "SCRAM-SHA-512"].client) # Add relation with dummy app await asyncio.gather( - ops_test.model.deploy(app_charm, application_name=DUMMY_NAME, num_units=1, series="jammy"), + ops_test.model.deploy( + app_charm, application_name=DUMMY_NAME, num_units=1, series="jammy", trust=True + ), ) await ops_test.model.add_relation(APP_NAME, f"{DUMMY_NAME}:{REL_NAME_ADMIN}") async with ops_test.fast_forward(fast_interval="60s"): await ops_test.model.wait_for_idle( - apps=[APP_NAME, DUMMY_NAME], idle_period=30, status="active", timeout=800 + apps=[APP_NAME, DUMMY_NAME], idle_period=30, status="active", timeout=2000 ) # check that client listener is active - assert check_socket(address, SECURITY_PROTOCOL_PORTS["SASL_PLAINTEXT", "SCRAM-SHA-512"].client) + assert netcat(address, SECURITY_PROTOCOL_PORTS["SASL_PLAINTEXT", "SCRAM-SHA-512"].client) - # remove relation and check that client listerner is not active + # remove relation and check that client listener is not active await ops_test.model.applications[APP_NAME].remove_relation( f"{APP_NAME}:{REL_NAME}", f"{DUMMY_NAME}:{REL_NAME_ADMIN}" ) @@ -116,9 +119,7 @@ async def test_listeners(ops_test: OpsTest, app_charm): apps=[APP_NAME], idle_period=30, status="active", timeout=600 ) - assert not check_socket( - address, SECURITY_PROTOCOL_PORTS["SASL_PLAINTEXT", "SCRAM-SHA-512"].client - ) + assert not netcat(address, SECURITY_PROTOCOL_PORTS["SASL_PLAINTEXT", "SCRAM-SHA-512"].client) @pytest.mark.abort_on_fail @@ -165,6 +166,11 @@ async def test_logs_write_to_storage(ops_test: OpsTest): ) +@pytest.mark.abort_on_fail +async def test_external_listeners_bootstrap(ops_test: OpsTest): + check_external_access_non_tls(ops_test, f"{APP_NAME}/0") + + @pytest.mark.abort_on_fail async def test_exporter_endpoints(ops_test: OpsTest): unit_address = await get_address(ops_test=ops_test) diff --git a/tests/integration/test_password_rotation.py b/tests/integration/test_password_rotation.py index d08caf7c..ef6b9ece 100644 --- a/tests/integration/test_password_rotation.py +++ b/tests/integration/test_password_rotation.py @@ -29,12 +29,14 @@ async def test_build_and_deploy(ops_test: OpsTest): channel="3/edge", application_name=ZK_NAME, num_units=3, + trust=True, ), ops_test.model.deploy( kafka_charm, application_name=APP_NAME, resources={"kafka-image": KAFKA_CONTAINER}, num_units=1, + trust=True, ), ) await ops_test.model.block_until(lambda: len(ops_test.model.applications[ZK_NAME].units) == 3) diff --git a/tests/integration/test_provider.py b/tests/integration/test_provider.py index dc2e4cab..277438d3 100644 --- a/tests/integration/test_provider.py +++ b/tests/integration/test_provider.py @@ -20,9 +20,11 @@ check_user, get_client_usernames, get_kafka_zk_relation_data, + get_node_port, get_provider_data, load_acls, load_super_users, + netcat, ) logger = logging.getLogger(__name__) @@ -41,14 +43,17 @@ async def test_deploy_charms_relate_active( """Test deploy and relate operations.""" charm = await ops_test.build_charm(".") await asyncio.gather( - ops_test.model.deploy(ZK_NAME, channel="3/edge", application_name=ZK_NAME, num_units=3), + ops_test.model.deploy( + ZK_NAME, channel="3/edge", application_name=ZK_NAME, num_units=3, trust=True + ), ops_test.model.deploy( charm, application_name=APP_NAME, num_units=1, resources={"kafka-image": KAFKA_CONTAINER}, + trust=True, ), - ops_test.model.deploy(app_charm, application_name=DUMMY_NAME_1, num_units=1), + ops_test.model.deploy(app_charm, application_name=DUMMY_NAME_1, num_units=1, trust=True), ) await ops_test.model.add_relation(APP_NAME, ZK_NAME) await ops_test.model.add_relation(APP_NAME, f"{DUMMY_NAME_1}:{REL_NAME_CONSUMER}") @@ -90,7 +95,7 @@ async def test_deploy_multiple_charms_same_topic_relate_active( ops_test: OpsTest, app_charm: PosixPath, usernames: Set[str] ): """Test relation with multiple applications.""" - await ops_test.model.deploy(app_charm, application_name=DUMMY_NAME_2, num_units=1) + await ops_test.model.deploy(app_charm, application_name=DUMMY_NAME_2, num_units=1, trust=True) await ops_test.model.add_relation(APP_NAME, f"{DUMMY_NAME_2}:{REL_NAME_CONSUMER}") async with ops_test.fast_forward(fast_interval="60s"): @@ -157,7 +162,11 @@ async def test_deploy_producer_same_topic( """Test the correct deployment and relation with role producer.""" await asyncio.gather( ops_test.model.deploy( - app_charm, application_name=DUMMY_NAME_1, num_units=1, series="jammy" + app_charm, + application_name=DUMMY_NAME_1, + num_units=1, + series="jammy", + trust=True, ) ) await ops_test.model.add_relation(APP_NAME, f"{DUMMY_NAME_1}:{REL_NAME_PRODUCER}") @@ -201,7 +210,11 @@ async def test_admin_added_to_super_users(ops_test: OpsTest): await asyncio.gather( ops_test.model.deploy( - app_charm, application_name=DUMMY_NAME_1, num_units=1, series="jammy" + app_charm, + application_name=DUMMY_NAME_1, + num_units=1, + series="jammy", + trust=True, ) ) await ops_test.model.wait_for_idle(apps=[APP_NAME, DUMMY_NAME_1, ZK_NAME]) @@ -236,7 +249,7 @@ async def test_admin_removed_from_super_users(ops_test: OpsTest): async def test_connection_updated_on_tls_enabled(ops_test: OpsTest, app_charm: PosixPath): """Test relation when TLS is enabled.""" # adding new app unit to validate - await ops_test.model.deploy(app_charm, application_name=DUMMY_NAME_1, num_units=1) + await ops_test.model.deploy(app_charm, application_name=DUMMY_NAME_1, num_units=1, trust=True) await ops_test.model.wait_for_idle(apps=[DUMMY_NAME_1]) await ops_test.model.add_relation(APP_NAME, f"{DUMMY_NAME_1}:{REL_NAME_CONSUMER}") await ops_test.model.wait_for_idle(apps=[APP_NAME, DUMMY_NAME_1]) @@ -244,7 +257,9 @@ async def test_connection_updated_on_tls_enabled(ops_test: OpsTest, app_charm: P # deploying tls tls_config = {"ca-common-name": "kafka"} # FIXME (certs): Unpin the revision once the charm is fixed - await ops_test.model.deploy(TLS_NAME, channel="edge", config=tls_config, revision=163) + await ops_test.model.deploy( + TLS_NAME, channel="edge", config=tls_config, revision=163, trust=True + ) await ops_test.model.wait_for_idle( apps=[TLS_NAME], idle_period=30, timeout=1800, status="active" ) @@ -276,8 +291,15 @@ async def test_connection_updated_on_tls_enabled(ops_test: OpsTest, app_charm: P owner=APP_NAME, ) + tls_bootstrap_port = str(get_node_port(ops_test, APP_NAME, "sasl-ssl-scram-bootstrap-port")) + assert provider_data["tls"] == "enabled" - assert "9093" in provider_data["endpoints"] + assert tls_bootstrap_port in provider_data["endpoints"] assert "2182" in provider_data["zookeeper-uris"] assert "test-prefix" in provider_data["consumer-group-prefix"] assert "test-topic" in provider_data["topic"] + assert len(provider_data["endpoints"].split(",")) == 1 # aka single bootstrap service returned + + provided_host, provided_port = provider_data["endpoints"].split(":") + + assert netcat(provided_host, int(provided_port)) diff --git a/tests/integration/test_scaling.py b/tests/integration/test_scaling.py index 3a88fce2..fdff6c39 100644 --- a/tests/integration/test_scaling.py +++ b/tests/integration/test_scaling.py @@ -25,12 +25,15 @@ async def test_kafka_simple_scale_up(ops_test: OpsTest): kafka_charm = await ops_test.build_charm(".") await asyncio.gather( - ops_test.model.deploy(ZK_NAME, channel="3/edge", application_name=ZK_NAME, num_units=1), + ops_test.model.deploy( + ZK_NAME, channel="3/edge", application_name=ZK_NAME, num_units=1, trust=True + ), ops_test.model.deploy( kafka_charm, application_name=APP_NAME, num_units=1, resources={"kafka-image": KAFKA_CONTAINER}, + trust=True, ), ) await ops_test.model.wait_for_idle(apps=[APP_NAME, ZK_NAME]) diff --git a/tests/integration/test_tls.py b/tests/integration/test_tls.py index 644896b7..1a7dbf14 100644 --- a/tests/integration/test_tls.py +++ b/tests/integration/test_tls.py @@ -42,8 +42,10 @@ async def test_deploy_tls(ops_test: OpsTest, app_charm): await asyncio.gather( # FIXME (certs): Unpin the revision once the charm is fixed - ops_test.model.deploy(TLS_NAME, channel="edge", config=tls_config, revision=163), - ops_test.model.deploy(ZK_NAME, channel="3/edge", num_units=3), + ops_test.model.deploy( + TLS_NAME, channel="edge", config=tls_config, revision=163, trust=True + ), + ops_test.model.deploy(ZK_NAME, channel="3/edge", num_units=3, trust=True), ops_test.model.deploy( kafka_charm, application_name=APP_NAME, @@ -51,6 +53,7 @@ async def test_deploy_tls(ops_test: OpsTest, app_charm): config={ "ssl_principal_mapping_rules": "RULE:^.*[Cc][Nn]=([a-zA-Z0-9.]*).*$/$1/L,DEFAULT" }, + trust=True, ), ) async with ops_test.fast_forward(fast_interval="20s"): @@ -121,9 +124,13 @@ async def test_kafka_tls(ops_test: OpsTest, app_charm): ) await asyncio.gather( - ops_test.model.deploy(app_charm, application_name=DUMMY_NAME, num_units=1, series="jammy"), + ops_test.model.deploy( + app_charm, application_name=DUMMY_NAME, num_units=1, series="jammy", trust=True + ), + ) + await ops_test.model.wait_for_idle( + apps=[APP_NAME, DUMMY_NAME], timeout=1000, idle_period=30, raise_on_error=False ) - await ops_test.model.wait_for_idle(apps=[APP_NAME, DUMMY_NAME], timeout=1000, idle_period=30) # ensuring at least a few update-status await ops_test.model.add_relation(APP_NAME, f"{DUMMY_NAME}:{REL_NAME_ADMIN}") diff --git a/tests/integration/test_upgrade.py b/tests/integration/test_upgrade.py index a563c418..de5cd52c 100644 --- a/tests/integration/test_upgrade.py +++ b/tests/integration/test_upgrade.py @@ -30,6 +30,7 @@ async def test_in_place_upgrade(ops_test: OpsTest, kafka_charm, app_charm): channel=CHANNEL, application_name=ZK_NAME, num_units=1, + trust=True, ), ops_test.model.deploy( APP_NAME, @@ -38,7 +39,9 @@ async def test_in_place_upgrade(ops_test: OpsTest, kafka_charm, app_charm): channel=CHANNEL, trust=True, ), - ops_test.model.deploy(app_charm, application_name=DUMMY_NAME, num_units=1, series="jammy"), + ops_test.model.deploy( + app_charm, application_name=DUMMY_NAME, num_units=1, series="jammy", trust=True + ), ) await ops_test.model.add_relation(APP_NAME, ZK_NAME) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 59ae7cbb..e3cf56a9 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -113,3 +113,14 @@ def user_tasks() -> dict: content = f.read() return json.loads(content) + + +@pytest.fixture(autouse=True) +def patched_node_ip(): + if SUBSTRATE == "k8s": + with patch( + "core.models.KafkaBroker.node_ip", new_callable=PropertyMock, return_value="1234" + ) as patched_node_ip: + yield patched_node_ip + else: + yield diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 3fddedeb..924b1c82 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -52,6 +52,7 @@ def harness() -> Harness: { "log_retention_ms": "-1", "compression_type": "producer", + "expose-external": "none", } ) harness.begin() @@ -352,7 +353,7 @@ def test_update_status_blocks_if_machine_not_configured( patch("events.upgrade.KafkaUpgrade.idle", return_value=True), ): harness.charm.on.update_status.emit() - assert harness.charm.unit.status == Status.SNAP_NOT_RUNNING.value.status + assert harness.charm.unit.status == Status.BROKER_NOT_RUNNING.value.status @pytest.mark.skipif(SUBSTRATE == "k8s", reason="sysctl config not used on K8s") @@ -528,7 +529,7 @@ def test_zookeeper_broken_stops_service_and_removes_meta_properties(harness: Har harness.remove_relation(zk_rel_id) patched_stop_snap_service.assert_called_once() - assert re.match(r"rm .*/meta.properties", " ".join(patched_exec.call_args_list[0].args[0])) + assert re.match(r"rm .*/meta.properties", " ".join(patched_exec.call_args_list[1].args[0])) assert isinstance(harness.charm.unit.status, BlockedStatus) diff --git a/tests/unit/test_charm_balancer.py b/tests/unit/test_charm_balancer.py index 8e9f9d7e..15758e6f 100644 --- a/tests/unit/test_charm_balancer.py +++ b/tests/unit/test_charm_balancer.py @@ -6,7 +6,7 @@ import logging import re from pathlib import Path -from unittest.mock import patch +from unittest.mock import PropertyMock, patch import pytest import yaml @@ -158,6 +158,7 @@ def test_ready_to_start_no_peer_cluster(charm_configuration): def test_ready_to_start_no_zk_data(charm_configuration, base_state: State): # Given charm_configuration["options"]["roles"]["default"] = "balancer,broker" + charm_configuration["options"]["expose-external"]["default"] = "none" ctx = Context( KafkaCharm, meta=METADATA, @@ -182,6 +183,7 @@ def test_ready_to_start_no_zk_data(charm_configuration, base_state: State): def test_ready_to_start_no_broker_data(charm_configuration, base_state: State, zk_data): # Given charm_configuration["options"]["roles"]["default"] = "balancer,broker" + charm_configuration["options"]["expose-external"]["default"] = "none" ctx = Context( KafkaCharm, meta=METADATA, @@ -204,9 +206,11 @@ def test_ready_to_start_no_broker_data(charm_configuration, base_state: State, z def test_ready_to_start_ok(charm_configuration, base_state: State, zk_data): # Given charm_configuration["options"]["roles"]["default"] = "balancer,broker" + charm_configuration["options"]["expose-external"]["default"] = "none" ctx = Context( KafkaCharm, meta=METADATA, config=charm_configuration, actions=ACTIONS, unit_id=0 ) + restart_peer = PeerRelation("restart", "restart") cluster_peer = PeerRelation( PEER, local_app_data={f"{user}-password": "pwd" for user in INTERNAL_USERS}, @@ -227,18 +231,51 @@ def test_ready_to_start_ok(charm_configuration, base_state: State, zk_data): }, ) - relation = Relation(interface=ZK, endpoint=ZK, remote_app_name=ZK, remote_app_data=zk_data) - state_in = base_state.replace(relations=[cluster_peer, relation], planned_units=3) + relation = Relation(interface=ZK, endpoint=ZK, remote_app_name=ZK) + state_in = base_state.replace( + relations=[cluster_peer, relation, restart_peer], planned_units=3 + ) # When with ( patch("workload.BalancerWorkload.write") as patched_writer, patch("workload.BalancerWorkload.read"), + patch("workload.KafkaWorkload.read"), patch("workload.BalancerWorkload.exec"), patch("workload.BalancerWorkload.restart"), patch("workload.KafkaWorkload.start"), patch("workload.BalancerWorkload.active", return_value=True), + patch("workload.KafkaWorkload.active", return_value=True), patch("core.models.ZooKeeper.broker_active", return_value=True), + patch( + "core.models.ZooKeeper.zookeeper_connected", + new_callable=PropertyMock, + return_value=True, + ), + patch( + "core.models.PeerCluster.broker_connected", + new_callable=PropertyMock, + return_value=True, + ), + patch( + "managers.config.ConfigManager.server_properties", + new_callable=PropertyMock, + return_value=[], + ), + patch( + "managers.config.BalancerConfigManager.cruise_control_properties", + new_callable=PropertyMock, + return_value=[], + ), + patch( + "managers.config.ConfigManager.jaas_config", new_callable=PropertyMock, return_value="" + ), + patch( + "managers.config.BalancerConfigManager.jaas_config", + new_callable=PropertyMock, + return_value="", + ), + patch("health.KafkaHealth.machine_configured", return_value=True), ): state_out = ctx.run("start", state_in) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 6f5c9fcf..1cf218d9 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -2,6 +2,7 @@ # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. +import logging import os from pathlib import Path from unittest.mock import PropertyMock, mock_open, patch @@ -31,6 +32,8 @@ pytestmark = pytest.mark.broker +logger = logging.getLogger(__name__) + BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..")) CONFIG = str(yaml.safe_load(Path(BASE_DIR + "/config.yaml").read_text())) ACTIONS = str(yaml.safe_load(Path(BASE_DIR + "/actions.yaml").read_text())) @@ -49,6 +52,7 @@ def harness(): { "log_retention_ms": "-1", "compression_type": "producer", + "expose-external": "none", } ) harness.begin() @@ -141,7 +145,7 @@ def test_listeners_in_server_properties(harness: Harness[KafkaCharm]): host = "treebeard" if SUBSTRATE == "vm" else "kafka-k8s-0.kafka-k8s-endpoints" sasl_pm = "SASL_PLAINTEXT_SCRAM_SHA_512" - expected_listeners = f"listeners=INTERNAL_{sasl_pm}://:19092" + expected_listeners = f"listeners=INTERNAL_{sasl_pm}://0.0.0.0:19092" expected_advertised_listeners = f"advertised.listeners=INTERNAL_{sasl_pm}://{host}:19092" with patch( @@ -190,9 +194,9 @@ def test_oauth_client_listeners_in_server_properties(harness: Harness[KafkaCharm oauth_client_protocol, oauth_client_port = "CLIENT_SASL_PLAINTEXT_OAUTHBEARER", "9095" expected_listeners = ( - f"listeners={internal_protocol}://:{internal_port}," - f"{scram_client_protocol}://:{scram_client_port}," - f"{oauth_client_protocol}://:{oauth_client_port}" + f"listeners={internal_protocol}://0.0.0.0:{internal_port}," + f"{scram_client_protocol}://0.0.0.0:{scram_client_port}," + f"{oauth_client_protocol}://0.0.0.0:{oauth_client_port}" ) expected_advertised_listeners = ( f"advertised.listeners={internal_protocol}://{host}:{internal_port}," @@ -242,9 +246,7 @@ def test_ssl_listeners_in_server_properties(harness: Harness[KafkaCharm]): host = "treebeard" if SUBSTRATE == "vm" else "kafka-k8s-0.kafka-k8s-endpoints" sasl_pm = "SASL_SSL_SCRAM_SHA_512" ssl_pm = "SSL_SSL" - expected_listeners = ( - f"listeners=INTERNAL_{sasl_pm}://:19093,CLIENT_{sasl_pm}://:9093,CLIENT_{ssl_pm}://:9094" - ) + expected_listeners = f"listeners=INTERNAL_{sasl_pm}://0.0.0.0:19093,CLIENT_{sasl_pm}://0.0.0.0:9093,CLIENT_{ssl_pm}://0.0.0.0:9094" expected_advertised_listeners = f"advertised.listeners=INTERNAL_{sasl_pm}://{host}:19093,CLIENT_{sasl_pm}://{host}:9093,CLIENT_{ssl_pm}://{host}:9094" with patch( diff --git a/tests/unit/test_provider.py b/tests/unit/test_provider.py index 94b53a5b..79ec6369 100644 --- a/tests/unit/test_provider.py +++ b/tests/unit/test_provider.py @@ -34,6 +34,7 @@ def harness(): { "log_retention_ms": "-1", "compression_type": "producer", + "expose-external": "none", } ) diff --git a/tests/unit/test_tls.py b/tests/unit/test_tls.py index ef7ea709..6ea6ade4 100644 --- a/tests/unit/test_tls.py +++ b/tests/unit/test_tls.py @@ -4,11 +4,11 @@ import socket from pathlib import Path -from unittest.mock import patch +from unittest.mock import PropertyMock, patch import pytest import yaml -from ops.model import ActiveStatus, BlockedStatus +from ops.model import ActiveStatus from ops.testing import Harness from charm import KafkaCharm @@ -33,6 +33,7 @@ def harness(): { "log_retention_ms": "-1", "compression_type": "producer", + "expose-external": "none", } ) harness.begin() @@ -64,7 +65,9 @@ def harness(): return harness -def test_blocked_if_trusted_certificate_added_before_tls_relation(harness: Harness[KafkaCharm]): +def test_mtls_not_enabled_if_trusted_certificate_added_before_tls_relation( + harness: Harness[KafkaCharm], +): # Create peer relation peer_relation_id = harness.add_relation(PEER, CHARM_KEY) harness.add_relation_unit(peer_relation_id, f"{CHARM_KEY}/1") @@ -75,7 +78,7 @@ def test_blocked_if_trusted_certificate_added_before_tls_relation(harness: Harne harness.set_leader(True) harness.add_relation("trusted-certificate", "tls-one") - assert isinstance(harness.charm.app.status, BlockedStatus) + assert not harness.charm.state.cluster.mtls_enabled def test_mtls_flag_added(harness: Harness[KafkaCharm]): @@ -90,8 +93,7 @@ def test_mtls_flag_added(harness: Harness[KafkaCharm]): harness.set_leader(True) harness.add_relation("trusted-certificate", "tls-one") - peer_relation_data = harness.get_relation_data(peer_relation_id, CHARM_KEY) - assert peer_relation_data.get("mtls", "disabled") == "enabled" + assert harness.charm.state.cluster.mtls_enabled assert isinstance(harness.charm.app.status, ActiveStatus) @@ -103,37 +105,56 @@ def test_extra_sans_config(harness: Harness[KafkaCharm]): peer_relation_id, f"{CHARM_KEY}/0", {"private-address": "treebeard"} ) - harness.update_config({"certificate_extra_sans": ""}) - assert harness.charm.tls._extra_sans == [] + manager = harness.charm.broker.tls_manager - harness.update_config({"certificate_extra_sans": "worker{unit}.com"}) - assert harness.charm.tls._extra_sans == ["worker0.com"] + harness._update_config({"certificate_extra_sans": ""}) + manager.config = harness.charm.config + assert manager._build_extra_sans() == [] + + harness._update_config({"certificate_extra_sans": "worker{unit}.com"}) + manager.config = harness.charm.config + assert "worker0.com" in "".join(manager._build_extra_sans()) - harness.update_config({"certificate_extra_sans": "worker{unit}.com,{unit}.example"}) - assert harness.charm.tls._extra_sans == ["worker0.com", "0.example"] + harness._update_config({"certificate_extra_sans": "worker{unit}.com,{unit}.example"}) + manager.config = harness.charm.config + assert "worker0.com" in "".join(manager._build_extra_sans()) + assert "0.example" in "".join(manager._build_extra_sans()) -def test_sans(harness: Harness[KafkaCharm]): +def test_sans(harness: Harness[KafkaCharm], patched_node_ip): # Create peer relation peer_relation_id = harness.add_relation(PEER, CHARM_KEY) harness.add_relation_unit(peer_relation_id, f"{CHARM_KEY}/0") harness.update_relation_data( peer_relation_id, f"{CHARM_KEY}/0", {"private-address": "treebeard"} ) + + manager = harness.charm.broker.tls_manager harness.update_config({"certificate_extra_sans": "worker{unit}.com"}) + manager.config = harness.charm.config sock_dns = socket.getfqdn() if SUBSTRATE == "vm": - assert harness.charm.tls._sans == { + assert manager.build_sans() == { "sans_ip": ["treebeard"], "sans_dns": [f"{CHARM_KEY}/0", sock_dns, "worker0.com"], } elif SUBSTRATE == "k8s": # NOTE previous k8s sans_ip like kafka-k8s-0.kafka-k8s-endpoints or binding pod address - with patch("ops.model.Model.get_binding"): - assert harness.charm.tls._sans["sans_dns"] == [ - "kafka-k8s-0", - "kafka-k8s-0.kafka-k8s-endpoints", - sock_dns, - "worker0.com", - ] + with ( + patch("ops.model.Model.get_binding"), + patch( + "core.models.KafkaBroker.node_ip", + new_callable=PropertyMock, + return_value="palantir", + ), + ): + assert sorted(manager.build_sans()["sans_dns"]) == sorted( + [ + "kafka-k8s-0", + "kafka-k8s-0.kafka-k8s-endpoints", + sock_dns, + "worker0.com", + ] + ) + assert "palantir" in "".join(manager.build_sans()["sans_ip"]) diff --git a/tests/unit/test_upgrade.py b/tests/unit/test_upgrade.py index aac9159c..757d511f 100644 --- a/tests/unit/test_upgrade.py +++ b/tests/unit/test_upgrade.py @@ -48,6 +48,7 @@ def harness(zk_data): { "log_retention_ms": "-1", "compression_type": "producer", + "expose-external": "none", } ) harness.begin() @@ -169,9 +170,10 @@ def test_upgrade_granted_sets_failed_if_failed_snap(harness: Harness[KafkaCharm] patch( "events.upgrade.KafkaUpgrade.zookeeper_current_version", new_callable=PropertyMock, - return_value="3.6", + return_value="3.6.2", ), patch("workload.KafkaWorkload.stop") as patched_stop, + patch("workload.BalancerWorkload.stop"), patch("workload.KafkaWorkload.install", return_value=False), ): mock_event = MagicMock() @@ -199,6 +201,7 @@ def test_upgrade_sets_failed_if_failed_upgrade_check( patch("workload.KafkaWorkload.start") as patched_start, patch("workload.KafkaWorkload.stop"), patch("workload.KafkaWorkload.install"), + patch("workload.BalancerWorkload.stop"), patch( "events.broker.BrokerOperator.healthy", new_callable=PropertyMock, return_value=False ), @@ -237,6 +240,7 @@ def test_upgrade_succeeds(harness: Harness[KafkaCharm], upgrade_func: str): patch("workload.KafkaWorkload.stop"), patch("workload.KafkaWorkload.install"), patch("workload.KafkaWorkload.active", new_callable=PropertyMock, return_value=True), + patch("workload.BalancerWorkload.stop"), patch( "events.broker.BrokerOperator.healthy", new_callable=PropertyMock, return_value=True ), @@ -271,7 +275,7 @@ def test_upgrade_granted_recurses_upgrade_changed_on_leader(harness: Harness[Kaf patch( "events.upgrade.KafkaUpgrade.zookeeper_current_version", new_callable=PropertyMock, - return_value="3.6", + return_value="3.6.2", ), patch("time.sleep"), patch("workload.KafkaWorkload.stop"), @@ -280,6 +284,7 @@ def test_upgrade_granted_recurses_upgrade_changed_on_leader(harness: Harness[Kaf patch( "events.broker.BrokerOperator.healthy", new_callable=PropertyMock, return_value=True ), + patch("workload.BalancerWorkload.stop"), patch("events.upgrade.KafkaUpgrade.on_upgrade_changed") as patched_upgrade, ): mock_event = MagicMock() diff --git a/tox.ini b/tox.ini index 9a935edf..4216942f 100644 --- a/tox.ini +++ b/tox.ini @@ -58,9 +58,11 @@ commands = --skip {tox_root}/lib \ --skip {tox_root}/tests/integration/*/lib \ --skip {tox_root}/venv \ + --skip {tox_root}/tests/integration/app-charm/lib/charms/data_platform_libs/v0/data_interfaces.py \ --skip {tox_root}/.mypy_cache \ --skip {tox_root}/icon.svg \ --skip {tox_root}/poetry.lock + poetry run codespell {[vars]lib_path} poetry run ruff check {[vars]all_path} @@ -74,7 +76,7 @@ description = Run unit tests commands = poetry install --no-root --with unit poetry run coverage run --source={[vars]src_path} \ - -m pytest -v --tb native -s {posargs} {[vars]tests_path}/unit + -m pytest -v --tb native -s {posargs} {[vars]tests_path}/unit -- -vv poetry run coverage report poetry run coverage xml