From 0b8d0583c0894566b7cba61bbfc0d8c176836eb3 Mon Sep 17 00:00:00 2001 From: Claudio Matsuoka Date: Fri, 21 Jun 2024 14:25:59 -0300 Subject: [PATCH 1/4] fix: check if lxd snap is installed (#585) Check if the lxd snap is installed by querying the snapd socket instead of looking for an executable in the path, as lxd can use auto-installer stubs. Co-authored-by: Callahan Kovacs Signed-off-by: Claudio Matsuoka --- craft_providers/lxd/installer.py | 45 +++++++++++++++++++++++++++++-- pyproject.toml | 2 ++ tests/unit/lxd/test_installer.py | 46 +++++++++++++++++++++++++++----- 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/craft_providers/lxd/installer.py b/craft_providers/lxd/installer.py index 887cc592..71e3c4c8 100644 --- a/craft_providers/lxd/installer.py +++ b/craft_providers/lxd/installer.py @@ -19,10 +19,14 @@ import logging import os +import pathlib import shutil import subprocess import sys +import requests +import requests_unixsocket # type: ignore + from craft_providers.errors import details_from_called_process_error from . import errors @@ -52,6 +56,7 @@ def install(sudo: bool = True) -> str: cmd += ["snap", "install", "lxd"] + logger.debug("installing LXD") try: subprocess.run(cmd, check=True) except subprocess.CalledProcessError as error: @@ -62,6 +67,8 @@ def install(sudo: bool = True) -> str: lxd = LXD() lxd.wait_ready(sudo=sudo) + + logger.debug("initialising LXD") lxd.init(auto=True, sudo=sudo) if not is_user_permitted(): @@ -96,11 +103,45 @@ def is_initialized(*, remote: str, lxc: LXC) -> bool: def is_installed() -> bool: - """Check if LXD is installed (and found on PATH). + """Check if LXD is installed. :returns: True if lxd is installed. """ - return shutil.which("lxd") is not None + logger.debug("Checking if LXD is installed.") + + # check if non-snap lxd socket exists (for Arch or NixOS) + if ( + pathlib.Path("/var/lib/lxd/unix.socket").is_socket() + and shutil.which("lxd") is not None + ): + return True + + # query snapd API + url = "http+unix://%2Frun%2Fsnapd.socket/v2/snaps/lxd" + try: + snap_info = requests_unixsocket.get(url=url, params={"select": "enabled"}) + except requests.exceptions.ConnectionError as error: + raise errors.ProviderError( + brief="Unable to connect to snapd service." + ) from error + + try: + snap_info.raise_for_status() + except requests.exceptions.HTTPError as error: + logger.debug(f"Could not get snap info for LXD: {error}") + return False + + # the LXD snap should be installed and active but check the status + # for completeness + try: + status = snap_info.json()["result"]["status"] + except (TypeError, KeyError): + raise errors.ProviderError(brief="Unexpected response from snapd service.") + + logger.debug(f"LXD snap status: {status}") + # snap status can be "installed" or "active" - "installed" revisions + # are filtered from this API call with `select: enabled` + return bool(status == "active") and shutil.which("lxd") is not None def is_user_permitted() -> bool: diff --git a/pyproject.toml b/pyproject.toml index 83d04bb9..47abbfac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,8 @@ dynamic = ["version", "readme"] dependencies = [ "packaging>=14.1", "pydantic<2.0", + # see https://github.com/psf/requests/issues/6707 + "requests<2.32", "pyyaml", # see https://github.com/psf/requests/issues/6707 "requests<2.32", diff --git a/tests/unit/lxd/test_installer.py b/tests/unit/lxd/test_installer.py index b572408c..5f49c80e 100644 --- a/tests/unit/lxd/test_installer.py +++ b/tests/unit/lxd/test_installer.py @@ -16,11 +16,13 @@ # import os -import shutil import sys +from typing import Any, Dict from unittest import mock +from unittest.mock import call import pytest +from craft_providers.errors import ProviderError from craft_providers.lxd import ( LXC, LXD, @@ -301,13 +303,45 @@ def test_is_initialized_no_disk_device(devices): assert not initialized +@pytest.mark.parametrize(("has_lxd_executable"), [(True), (False)]) +@pytest.mark.parametrize(("has_nonsnap_socket"), [(True), (False)]) @pytest.mark.parametrize( - ("which", "installed"), [("/path/to/lxd", True), (None, False)] + ("status", "exception", "installed"), + [ + ({"result": {"status": "active"}}, None, True), + ({"result": {"status": "foo"}}, None, False), + ({}, ProviderError, False), + (None, ProviderError, False), + ], ) -def test_is_installed(which, installed, monkeypatch): - monkeypatch.setattr(shutil, "which", lambda x: which) - - assert is_installed() == installed +def test_is_installed( + mocker, has_nonsnap_socket, has_lxd_executable, status, exception, installed +): + class FakeSnapInfo: + def raise_for_status(self) -> None: + pass + + def json(self) -> Dict[str, Any]: + return status + + mock_get = mocker.patch("requests_unixsocket.get", return_value=FakeSnapInfo()) + mocker.patch("pathlib.Path.is_socket", return_value=has_nonsnap_socket) + mocker.patch("shutil.which", return_value="lxd" if has_lxd_executable else None) + + if has_nonsnap_socket and has_lxd_executable: + assert is_installed() + return + + if exception: + with pytest.raises(exception): + is_installed() + else: + assert is_installed() == (installed and has_lxd_executable) + + assert mock_get.mock_calls[0] == call( + url="http+unix://%2Frun%2Fsnapd.socket/v2/snaps/lxd", + params={"select": "enabled"}, + ) @pytest.mark.skipif(sys.platform != "linux", reason=f"unsupported on {sys.platform}") From 8b0068249b51f9eaa56fedd7b2576eb0f30928a2 Mon Sep 17 00:00:00 2001 From: Claudio Matsuoka Date: Mon, 24 Jun 2024 16:25:14 -0300 Subject: [PATCH 2/4] fix: handle ill-formed profiles created by older rockcraft Older versions of rockcraft may have created a rockcraft lxd project containing a broken default profile. If this is the case, tell the user to delete the project and start over. Signed-off-by: Claudio Matsuoka --- craft_providers/lxd/installer.py | 10 +++++++--- craft_providers/lxd/lxd_provider.py | 22 +++++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/craft_providers/lxd/installer.py b/craft_providers/lxd/installer.py index 71e3c4c8..d49b12c5 100644 --- a/craft_providers/lxd/installer.py +++ b/craft_providers/lxd/installer.py @@ -79,7 +79,7 @@ def install(sudo: bool = True) -> str: return lxd.version() -def is_initialized(*, remote: str, lxc: LXC) -> bool: +def is_initialized(*, remote: str, project: str, lxc: LXC) -> bool: """Verify that LXD has been initialized and configuration looks valid. If LXD has been installed but the user has not initialized it (lxd init), @@ -153,7 +153,11 @@ def is_user_permitted() -> bool: def ensure_lxd_is_ready( - *, remote: str = "local", lxc: LXC = LXC(), lxd: LXD = LXD() + *, + remote: str = "local", + project: str = "default", + lxc: LXC = LXC(), + lxd: LXD = LXD(), ) -> None: """Ensure LXD is ready for use. @@ -185,7 +189,7 @@ def ensure_lxd_is_ready( ), ) - if not is_initialized(lxc=lxc, remote=remote): + if not is_initialized(lxc=lxc, project=project, remote=remote): raise errors.LXDError( brief="LXD has not been properly initialized.", details=( diff --git a/craft_providers/lxd/lxd_provider.py b/craft_providers/lxd/lxd_provider.py index ac6b862e..bae50954 100644 --- a/craft_providers/lxd/lxd_provider.py +++ b/craft_providers/lxd/lxd_provider.py @@ -26,7 +26,7 @@ from craft_providers.base import Base from craft_providers.errors import BaseConfigurationError -from .errors import LXDError, LXDUnstableImageError +from .errors import LXD_INSTALL_HELP, LXDError, LXDUnstableImageError from .installer import ensure_lxd_is_ready, install, is_installed from .launcher import launch from .lxc import LXC @@ -126,6 +126,26 @@ def launched_environment( :raises LXDError: if instance cannot be configured and launched. """ + projects = self.lxc.project_list(remote=self.lxd_remote) + if self.lxd_project in projects: + devices = self.lxc.profile_show( + project=self.lxd_project, profile="default", remote=self.lxd_remote + ).get("devices") + if not devices: + # Project "rockcraft" exists but the default profile is ill-formed, + # delete the project and start over. + raise LXDError( + brief="LXD project has an ill-formed default profile.", + details=( + f"The default profile in the LXD project '{self.lxd_project}' " + "has an empty devices section." + ), + resolution=( + "Delete the 'rockcraft' LXD project, it will be recreated in the " + "next execution.\n" + LXD_INSTALL_HELP + ), + ) + if build_base: logger.warning( "Deprecated: Parameter 'build_base' is deprecated and should " From 9cb2ed5ee3dca1f11b82fa8dcc4af99281ef3c7d Mon Sep 17 00:00:00 2001 From: Claudio Matsuoka Date: Mon, 24 Jun 2024 17:32:46 -0300 Subject: [PATCH 3/4] fix: replace rockcraft with project name variable Signed-off-by: Claudio Matsuoka --- craft_providers/lxd/lxd_provider.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/craft_providers/lxd/lxd_provider.py b/craft_providers/lxd/lxd_provider.py index bae50954..6680743f 100644 --- a/craft_providers/lxd/lxd_provider.py +++ b/craft_providers/lxd/lxd_provider.py @@ -132,7 +132,7 @@ def launched_environment( project=self.lxd_project, profile="default", remote=self.lxd_remote ).get("devices") if not devices: - # Project "rockcraft" exists but the default profile is ill-formed, + # Project exists but the default profile is ill-formed, tell the user to # delete the project and start over. raise LXDError( brief="LXD project has an ill-formed default profile.", @@ -141,8 +141,8 @@ def launched_environment( "has an empty devices section." ), resolution=( - "Delete the 'rockcraft' LXD project, it will be recreated in the " - "next execution.\n" + LXD_INSTALL_HELP + f"Delete the '{self.lxd_project}' LXD project, it will be " + "recreated in the next execution.\n" + LXD_INSTALL_HELP ), ) From 09bb92f1b52265c06b49a17d7570e983f0d37beb Mon Sep 17 00:00:00 2001 From: Claudio Matsuoka Date: Mon, 24 Jun 2024 17:37:12 -0300 Subject: [PATCH 4/4] chore: remove unused parameters Signed-off-by: Claudio Matsuoka --- craft_providers/lxd/installer.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/craft_providers/lxd/installer.py b/craft_providers/lxd/installer.py index d49b12c5..71e3c4c8 100644 --- a/craft_providers/lxd/installer.py +++ b/craft_providers/lxd/installer.py @@ -79,7 +79,7 @@ def install(sudo: bool = True) -> str: return lxd.version() -def is_initialized(*, remote: str, project: str, lxc: LXC) -> bool: +def is_initialized(*, remote: str, lxc: LXC) -> bool: """Verify that LXD has been initialized and configuration looks valid. If LXD has been installed but the user has not initialized it (lxd init), @@ -153,11 +153,7 @@ def is_user_permitted() -> bool: def ensure_lxd_is_ready( - *, - remote: str = "local", - project: str = "default", - lxc: LXC = LXC(), - lxd: LXD = LXD(), + *, remote: str = "local", lxc: LXC = LXC(), lxd: LXD = LXD() ) -> None: """Ensure LXD is ready for use. @@ -189,7 +185,7 @@ def ensure_lxd_is_ready( ), ) - if not is_initialized(lxc=lxc, project=project, remote=remote): + if not is_initialized(lxc=lxc, remote=remote): raise errors.LXDError( brief="LXD has not been properly initialized.", details=(