Skip to content
Draft
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
121 changes: 99 additions & 22 deletions lib/charms/operator_libs_linux/v2/snap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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]

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

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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

Expand All @@ -1024,6 +1090,7 @@ def _load_info(self, name: str) -> Snap:
revision=info["revision"],
confinement=info["confinement"],
apps=None,
version=info.get("version"),
)


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

Expand All @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "*"
Expand Down
13 changes: 7 additions & 6 deletions src/mysql_vm_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import shutil
import subprocess
import tempfile
import time
import typing
from collections.abc import Iterable
from typing import Any
Expand Down Expand Up @@ -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")

Expand Down
7 changes: 0 additions & 7 deletions tests/unit/test_mysqlsh_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading