Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 43 additions & 11 deletions lib/charms/operator_libs_linux/v2/snap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
84 changes: 71 additions & 13 deletions tests/unit/test_snap.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@
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
from charms.operator_libs_linux.v2 import snap

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,
Expand Down Expand Up @@ -103,7 +103,7 @@
}
"""

installed_result = r"""
installed_snaps_response = r"""
{
"type": "sync",
"status-code": 200,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)

Expand All @@ -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()

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"