diff --git a/lib/charms/operator_libs_linux/v2/snap.py b/lib/charms/operator_libs_linux/v2/snap.py index b0d65017..4370eb12 100644 --- a/lib/charms/operator_libs_linux/v2/snap.py +++ b/lib/charms/operator_libs_linux/v2/snap.py @@ -54,6 +54,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,6 +89,8 @@ TypeVar, ) +import opentelemetry.trace + if typing.TYPE_CHECKING: # avoid typing_extensions import at runtime from typing_extensions import NotRequired, ParamSpec, Required, Self, TypeAlias, Unpack @@ -93,6 +99,7 @@ _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 +109,9 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 12 +LIBPATCH = 13 + +PYDEPS = ["opentelemetry-api"] # Regex to locate 7-bit C1 ANSI sequences @@ -277,7 +286,9 @@ def _from_called_process_error(cls, msg: str, error: CalledProcessError) -> Self lines.extend(['Stderr:', error.stderr]) try: cmd = ['journalctl', '--unit', 'snapd', '--lines', '20'] - logs = subprocess.check_output(cmd, text=True) + 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: @@ -356,7 +367,9 @@ 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, stderr=subprocess.PIPE) + 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: msg = f'Snap: {self._name!r} -- command {args!r} failed!' raise SnapError._from_called_process_error(msg=msg, error=e) from e @@ -384,7 +397,9 @@ 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: msg = f'Snap: {self._name!r} -- command {args!r} failed!' raise SnapError._from_called_process_error(msg=msg, error=e) from e @@ -491,7 +506,9 @@ 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: msg = f'Snap: {self._name!r} -- command {args!r} failed!' raise SnapError._from_called_process_error(msg=msg, error=e) from e @@ -523,7 +540,9 @@ def alias(self, application: str, alias: str | None = None) -> None: alias = application args = ["snap", "alias", f"{self.name}.{application}", alias] 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: msg = f'Snap: {self._name!r} -- command {args!r} failed!' raise SnapError._from_called_process_error(msg=msg, error=e) from e @@ -932,15 +951,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.""" @@ -1280,7 +1304,13 @@ def install_local( if dangerous: args.append("--dangerous") try: - result = subprocess.check_output(args, text=True, stderr=subprocess.PIPE).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) @@ -1309,7 +1339,9 @@ def _system_set(config_item: str, value: str) -> None: """ args = ["snap", "set", "system", f"{config_item}={value}"] 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: msg = f"Failed setting system config '{config_item}' to '{value}'" raise SnapError._from_called_process_error(msg=msg, error=e) from e diff --git a/tests/unit/test_snap.py b/tests/unit/test_snap.py index 5247d4ce..88e3fe12 100644 --- a/tests/unit/test_snap.py +++ b/tests/unit/test_snap.py @@ -14,7 +14,7 @@ import unittest from subprocess import CalledProcessError from typing import Any, Iterable -from unittest.mock import MagicMock, mock_open, patch +from unittest.mock import ANY, MagicMock, mock_open, patch import fake_snapd as fake_snapd import pytest @@ -22,7 +22,7 @@ patch("charms.operator_libs_linux.v2.snap._cache_init", lambda x: x).start() -lazy_load_result = r""" +snap_information_response = r""" { "type": "sync", "status-code": 200, @@ -103,7 +103,7 @@ } """ -installed_result = r""" +installed_snaps_response = r""" { "type": "sync", "status-code": 200, @@ -195,6 +195,19 @@ } """ +installed_snap_apps_response = { + "type": "sync", + "result": [ + { + 'snap': 'juju', + 'name': 'fetch-oci', + 'daemon': 'oneshot', + 'daemon-scope': 'system', + 'enabled': True, + }, + ], +} + class SnapCacheTester(snap.SnapCache): def __init__(self): @@ -266,9 +279,9 @@ def test_can_lazy_load_snap_info(self, mock_exists, m): m.return_value.__next__ = lambda self: next(iter(self.readline, "")) mock_exists.return_value = True s = SnapCacheTester() - s._snap_client.get_snap_information.return_value = json.loads(lazy_load_result)["result"][ - 0 - ] + s._snap_client.get_snap_information.return_value = json.loads(snap_information_response)[ + "result" + ][0] s._load_available_snaps() self.assertIn("curl", s._snap_map) @@ -283,7 +296,9 @@ def test_can_lazy_load_snap_info(self, mock_exists, m): def test_can_load_installed_snap_info(self, mock_exists): mock_exists.return_value = True s = SnapCacheTester() - s._snap_client.get_installed_snaps.return_value = json.loads(installed_result)["result"] + s._snap_client.get_installed_snaps.return_value = json.loads(installed_snaps_response)[ + "result" + ] s._load_installed_snaps() @@ -615,20 +630,24 @@ def test_snap_unhold(self, mock_subprocess: MagicMock): @patch("charms.operator_libs_linux.v2.snap.SnapClient.get_installed_snap_apps") def test_apps_property(self, patched): s = SnapCacheTester() - s._snap_client.get_installed_snaps.return_value = json.loads(installed_result)["result"] + s._snap_client.get_installed_snaps.return_value = json.loads(installed_snaps_response)[ + "result" + ] s._load_installed_snaps() - patched.return_value = json.loads(installed_result)["result"][0]["apps"] + patched.return_value = json.loads(installed_snaps_response)["result"][0]["apps"] self.assertEqual(len(s["charmcraft"].apps), 2) self.assertIn({"snap": "charmcraft", "name": "charmcraft"}, s["charmcraft"].apps) @patch("charms.operator_libs_linux.v2.snap.SnapClient.get_installed_snap_apps") def test_services_property(self, patched): s = SnapCacheTester() - s._snap_client.get_installed_snaps.return_value = json.loads(installed_result)["result"] + s._snap_client.get_installed_snaps.return_value = json.loads(installed_snaps_response)[ + "result" + ] s._load_installed_snaps() - patched.return_value = json.loads(installed_result)["result"][0]["apps"] + patched.return_value = json.loads(installed_snaps_response)["result"][0]["apps"] self.assertEqual(len(s["charmcraft"].services), 1) self.assertDictEqual( s["charmcraft"].services, @@ -926,10 +945,10 @@ def setUp(self, mock_exists, m): mock_exists.return_value = True snap._Cache.cache = SnapCacheTester() snap._Cache.cache._snap_client.get_installed_snaps.return_value = json.loads( - installed_result + installed_snaps_response )["result"] snap._Cache.cache._snap_client.get_snap_information.return_value = json.loads( - lazy_load_result + snap_information_response )["result"][0] snap._Cache.cache._load_installed_snaps() snap._Cache.cache._load_available_snaps() @@ -1338,3 +1357,42 @@ def test_held(self, mock_subprocess: MagicMock): self.assertEqual(foo.held, False) mock_subprocess.return_value = {"hold:": "key isn't checked"} self.assertEqual(foo.held, True) + + +@pytest.fixture +def fake_request(monkeypatch: pytest.MonkeyPatch): + request = MagicMock() + monkeypatch.setattr("charms.operator_libs_linux.v2.snap.SnapClient._request", request) + return request + + +@pytest.fixture +def snap_client(): + return snap.SnapClient(socket_path="/does/not/exist") + + +def test_get_installed_snaps(snap_client: snap.SnapClient, fake_request: MagicMock): + fake_request.return_value = json.loads(installed_snaps_response)["result"] + rv = snap_client.get_installed_snaps() + charmcraft = next(snap for snap in rv if snap["name"] == "charmcraft") + assert charmcraft["version"] == "1.2.1" + + +def test_get_installed_snap_apps(snap_client: snap.SnapClient, fake_request: MagicMock): + fake_request.return_value = installed_snap_apps_response["result"] + rv = snap_client.get_installed_snap_apps("juju") + assert rv == [ + { + "name": "fetch-oci", + "snap": "juju", + "daemon": "oneshot", + "daemon-scope": ANY, + "enabled": ANY, + } + ] + + +def test_get_snap_information(snap_client: snap.SnapClient, fake_request: MagicMock): + fake_request.return_value = json.loads(snap_information_response)["result"] + rv = snap_client.get_snap_information("curl") + assert rv["version"] == "7.78.0"