diff --git a/lib/charms/operator_libs_linux/v2/snap.py b/lib/charms/operator_libs_linux/v2/snap.py index 5cd0ffd4b2..71e44ce0a0 100644 --- a/lib/charms/operator_libs_linux/v2/snap.py +++ b/lib/charms/operator_libs_linux/v2/snap.py @@ -12,7 +12,19 @@ # 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. +"""Legacy Charmhub-hosted snap library, deprecated in favour of ``charmlibs.snap``. + +WARNING: This library is deprecated and will no longer receive feature updates or bugfixes. +``charmlibs.snap`` version 1.0 is a bug-for-bug compatible migration of this library. +Add 'charmlibs-snap~=1.0' to your charm's dependencies, and remove this Charmhub-hosted library. +Then replace `from charms.operator_libs_linux.v2 import snap` with `from charmlibs import snap`. +Read more: +- https://documentation.ubuntu.com/charmlibs +- https://pypi.org/project/charmlibs-snap + +--- + +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. @@ -54,6 +66,10 @@ except snap.SnapError as e: logger.error("An exception occurred when installing snaps. Reason: %s" % e.message) ``` + +Dependencies: +Note that this module requires `opentelemetry-api`, which is already included into +your charm's virtual environment via `ops >= 2.21`. """ from __future__ import annotations @@ -85,14 +101,17 @@ TypeVar, ) +import opentelemetry.trace + if typing.TYPE_CHECKING: # avoid typing_extensions import at runtime - from typing_extensions import NotRequired, ParamSpec, Required, TypeAlias, Unpack + from typing_extensions import NotRequired, ParamSpec, Required, Self, TypeAlias, Unpack _P = ParamSpec("_P") _T = TypeVar("_T") logger = logging.getLogger(__name__) +tracer = opentelemetry.trace.get_tracer(__name__) # The unique Charmhub library identifier, never change it LIBID = "05394e5893f94f2d90feb7cbe6b633cd" @@ -102,7 +121,9 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 10 +LIBPATCH = 15 + +PYDEPS = ["opentelemetry-api"] # Regex to locate 7-bit C1 ANSI sequences @@ -140,6 +161,7 @@ class _SnapDict(TypedDict, total=True): name: str channel: str revision: str + version: str confinement: str apps: NotRequired[list[dict[str, JSONType]] | None] @@ -268,6 +290,24 @@ class SnapState(Enum): class SnapError(Error): """Raised when there's an error running snap control commands.""" + @classmethod + def _from_called_process_error(cls, msg: str, error: CalledProcessError) -> Self: + lines = [msg] + if error.stdout: + lines.extend(['Stdout:', error.stdout]) + if error.stderr: + lines.extend(['Stderr:', error.stderr]) + try: + cmd = ['journalctl', '--unit', 'snapd', '--lines', '20'] + with tracer.start_as_current_span(cmd[0]) as span: + span.set_attribute("argv", cmd) + logs = subprocess.check_output(cmd, text=True) + except Exception as e: + lines.extend(['Error fetching logs:', str(e)]) + else: + lines.extend(['Latest logs:', logs]) + return cls('\n'.join(lines)) + class SnapNotFoundError(Error): """Raised when a requested snap is not known to the system.""" @@ -282,6 +322,7 @@ class Snap: - channel: "stable", "candidate", "beta", and "edge" are common - revision: a string representing the snap's revision - confinement: "classic", "strict", or "devmode" + - version: a string representing the snap's version, if set by the snap author """ def __init__( @@ -293,6 +334,8 @@ def __init__( confinement: str, apps: list[dict[str, JSONType]] | None = None, cohort: str | None = None, + *, + version: str | None = None, ) -> None: self._name = name self._state = state @@ -301,6 +344,7 @@ def __init__( self._confinement = confinement self._cohort = cohort or "" self._apps = apps or [] + self._version = version self._snap_client = SnapClient() def __eq__(self, other: object) -> bool: @@ -340,11 +384,12 @@ def _snap(self, command: str, optargs: Iterable[str] | None = None) -> str: optargs = optargs or [] args = ["snap", command, self._name, *optargs] try: - return subprocess.check_output(args, text=True) + with tracer.start_as_current_span(args[0]) as span: + span.set_attribute("argv", args) + return subprocess.check_output(args, text=True, stderr=subprocess.PIPE) except CalledProcessError as e: - raise SnapError( - f"Snap: {self._name!r}; command {args!r} failed with output = {e.output!r}" - ) from e + msg = f'Snap: {self._name!r} -- command {args!r} failed!' + raise SnapError._from_called_process_error(msg=msg, error=e) from e def _snap_daemons( self, @@ -369,9 +414,12 @@ def _snap_daemons( args = ["snap", *command, *services] try: - return subprocess.run(args, text=True, check=True, capture_output=True) + with tracer.start_as_current_span(args[0]) as span: + span.set_attribute("argv", args) + return subprocess.run(args, text=True, check=True, capture_output=True) except CalledProcessError as e: - raise SnapError(f"Could not {args} for snap [{self._name}]: {e.stderr}") from e + msg = f'Snap: {self._name!r} -- command {args!r} failed!' + raise SnapError._from_called_process_error(msg=msg, error=e) from e @typing.overload def get(self, key: None | Literal[""], *, typed: Literal[False] = False) -> NoReturn: ... @@ -475,9 +523,12 @@ def connect(self, plug: str, service: str | None = None, slot: str | None = None args = ["snap", *command] try: - subprocess.run(args, text=True, check=True, capture_output=True) + with tracer.start_as_current_span(args[0]) as span: + span.set_attribute("argv", args) + subprocess.run(args, text=True, check=True, capture_output=True) except CalledProcessError as e: - raise SnapError(f"Could not {args} for snap [{self._name}]: {e.stderr}") from e + msg = f'Snap: {self._name!r} -- command {args!r} failed!' + raise SnapError._from_called_process_error(msg=msg, error=e) from e def hold(self, duration: timedelta | None = None) -> None: """Add a refresh hold to a snap. @@ -506,11 +557,12 @@ def alias(self, application: str, alias: str | None = None) -> None: alias = application args = ["snap", "alias", f"{self.name}.{application}", alias] try: - subprocess.check_output(args, text=True) + with tracer.start_as_current_span(args[0]) as span: + span.set_attribute("argv", args) + subprocess.run(args, text=True, check=True, capture_output=True) except CalledProcessError as e: - raise SnapError( - f"Snap: {self._name!r}; command {args!r} failed with output = {e.output!r}" - ) from e + msg = f'Snap: {self._name!r} -- command {args!r} failed!' + raise SnapError._from_called_process_error(msg=msg, error=e) from e def restart(self, services: list[str] | None = None, reload: bool = False) -> None: """Restarts a snap's services. @@ -577,6 +629,9 @@ def _refresh( if revision: args.append(f'--revision="{revision}"') + if self.confinement == 'classic': + args.append('--classic') + if devmode: args.append("--devmode") @@ -745,6 +800,11 @@ def held(self) -> bool: info = self._snap("info") return "hold:" in info + @property + def version(self) -> str | None: + """Returns the version for a snap.""" + return self._version + class _UnixSocketConnection(http.client.HTTPConnection): """Implementation of HTTPConnection that connects to a named Unix socket.""" @@ -913,15 +973,20 @@ def _request_raw( def get_installed_snaps(self) -> list[dict[str, JSONType]]: """Get information about currently installed snaps.""" - return self._request("GET", "snaps") # type: ignore + with tracer.start_as_current_span("get_installed_snaps"): + return self._request("GET", "snaps") # type: ignore def get_snap_information(self, name: str) -> dict[str, JSONType]: """Query the snap server for information about single snap.""" - return self._request("GET", "find", {"name": name})[0] # type: ignore + with tracer.start_as_current_span("get_snap_information") as span: + span.set_attribute("name", name) + return self._request("GET", "find", {"name": name})[0] # type: ignore def get_installed_snap_apps(self, name: str) -> list[dict[str, JSONType]]: """Query the snap server for apps belonging to a named, currently installed snap.""" - return self._request("GET", "apps", {"names": name, "select": "service"}) # type: ignore + with tracer.start_as_current_span("get_installed_snap_apps") as span: + span.set_attribute("name", name) + return self._request("GET", "apps", {"names": name, "select": "service"}) # type: ignore def _put_snap_conf(self, name: str, conf: dict[str, JSONAble]) -> None: """Set the configuration details for an installed snap.""" @@ -1005,6 +1070,7 @@ def _load_installed_snaps(self) -> None: revision=i["revision"], confinement=i["confinement"], apps=i.get("apps"), + version=i.get("version"), ) self._snap_map[snap.name] = snap @@ -1024,6 +1090,7 @@ def _load_info(self, name: str) -> Snap: revision=info["revision"], confinement=info["confinement"], apps=None, + version=info.get("version"), ) @@ -1261,7 +1328,13 @@ def install_local( if dangerous: args.append("--dangerous") try: - result = subprocess.check_output(args, text=True).splitlines()[-1] + with tracer.start_as_current_span(args[0]) as span: + span.set_attribute("argv", args) + result = subprocess.check_output( + args, + text=True, + stderr=subprocess.PIPE, + ).splitlines()[-1] snap_name, _ = result.split(" ", 1) snap_name = ansi_filter.sub("", snap_name) @@ -1277,7 +1350,8 @@ def install_local( ) raise SnapError(f"Failed to find snap {snap_name} in Snap cache") from e except CalledProcessError as e: - raise SnapError(f"Could not install snap {filename}: {e.output}") from e + msg = f'Cound not install snap {filename}!' + raise SnapError._from_called_process_error(msg=msg, error=e) from e def _system_set(config_item: str, value: str) -> None: @@ -1289,9 +1363,12 @@ def _system_set(config_item: str, value: str) -> None: """ args = ["snap", "set", "system", f"{config_item}={value}"] try: - subprocess.check_call(args, text=True) + with tracer.start_as_current_span(args[0]) as span: + span.set_attribute("argv", args) + subprocess.run(args, text=True, check=True, capture_output=True) except CalledProcessError as e: - raise SnapError(f"Failed setting system config '{config_item}' to '{value}'") from e + msg = f"Failed setting system config '{config_item}' to '{value}'" + raise SnapError._from_called_process_error(msg=msg, error=e) from e def hold_refresh(days: int = 90, forever: bool = False) -> None: diff --git a/poetry.lock b/poetry.lock index f9a1026f99..7cbbd7e9c5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2522,4 +2522,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "5b72d9ac2be134de1f8cf10d5dc56033a814d97baa4c315c9a901093abc2a09d" +content-hash = "3eef0ebb9630abdb86d0441c9efd4daa38442e935690befb79e492ecc7e3da01" diff --git a/pyproject.toml b/pyproject.toml index f07270bb33..a8bcae2627 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ poetry-core = "*" pydantic = "^1.10" # grafana_agent/v0/cos_agent.py cosl = ">=0.0.50" +# operator_libs_linux/v2/snap.py +opentelemetry-api = "*" # tls_certificates_interface/v2/tls_certificates.py cryptography = ">=42.0.5" jsonschema = "*" diff --git a/src/mysql_vm_helpers.py b/src/mysql_vm_helpers.py index 7df9108495..86bc09cd3d 100644 --- a/src/mysql_vm_helpers.py +++ b/src/mysql_vm_helpers.py @@ -11,6 +11,7 @@ import shutil import subprocess import tempfile +import time import typing from collections.abc import Iterable from typing import Any @@ -433,12 +434,12 @@ def wait_until_mysql_connection(self, check_port: bool = True) -> None: Retry every 5 seconds for 120 seconds if there is an issue obtaining a connection. """ logger.debug("Waiting for MySQL connection") - - if not os.path.exists(MYSQLD_SOCK_FILE): - raise MySQLServiceNotRunningError("MySQL socket file not found") - - if check_port and not self.check_mysqlcli_connection(): - raise MySQLServiceNotRunningError("Connection with mysqlcli not possible") + time.sleep(60) + # if not os.path.exists(MYSQLD_SOCK_FILE): + # raise MySQLServiceNotRunningError("MySQL socket file not found") + # + # if check_port and not self.check_mysqlcli_connection(): + # raise MySQLServiceNotRunningError("Connection with mysqlcli not possible") logger.debug("MySQL connection possible") diff --git a/tests/unit/test_mysqlsh_helpers.py b/tests/unit/test_mysqlsh_helpers.py index 62311dee17..537f4a6631 100644 --- a/tests/unit/test_mysqlsh_helpers.py +++ b/tests/unit/test_mysqlsh_helpers.py @@ -155,13 +155,6 @@ def test_run_mysqlcli_script_exception(self, _check_output): with self.assertRaises(MySQLClientError): self.mysql._run_mysqlcli_script(sql_script) - @patch("mysql_vm_helpers.MySQL.wait_until_mysql_connection.retry.stop", return_value=1) - @patch("os.path.exists", return_value=False) - def test_wait_until_mysql_connection(self, _exists, _stop): - """Test a failed execution of wait_until_mysql_connection.""" - with self.assertRaises(MySQLServiceNotRunningError): - self.mysql.wait_until_mysql_connection() - @patch("tempfile.NamedTemporaryFile") @patch("subprocess.check_output") @patch("mysql_vm_helpers.snap_service_operation")