Skip to content

Commit

Permalink
feat: install Ubuntu Pro Client in LXD instance (#664)
Browse files Browse the repository at this point in the history
* add install_pro_client and is_pro_installed functions

* add unit tests

* add integration test for install_pro_client

* add/adjust tests to take into account whether the pro client is installed

* fix integration tests

* address review comments

* build(deps): bump canonical-sphinx to 0.2.0 (#676)

Signed-off-by: Callahan Kovacs <[email protected]>

---------

Signed-off-by: Callahan Kovacs <[email protected]>
Co-authored-by: Callahan <[email protected]>
  • Loading branch information
linostar and mr-cal authored Oct 8, 2024
1 parent c511987 commit 09caf83
Show file tree
Hide file tree
Showing 7 changed files with 498 additions and 76 deletions.
166 changes: 143 additions & 23 deletions craft_providers/lxd/lxc.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#

"""LXC wrapper."""

import contextlib
import enum
import json
Expand Down Expand Up @@ -1197,17 +1198,24 @@ def is_pro_enabled(
]

try:
proc = self._run_lxc(command, capture_output=True, project=project)
data = json.loads(proc.stdout)
if data.get("result") == "success":
if data.get("data", {}).get("attributes", {}).get("is_attached"):
logger.debug("Managed instance is Pro enabled.")
return True
logger.debug("Managed instance is not Pro enabled.")
return False
proc = self._run_lxc(
command, capture_output=True, check=False, project=project
)
if proc.returncode == 0:
data = json.loads(proc.stdout)
if data.get("result") == "success":
if data.get("data", {}).get("attributes", {}).get("is_attached"):
logger.debug("Managed instance is Pro enabled.")
return True
logger.debug("Managed instance is not Pro enabled.")
return False

raise LXDError(
brief=f"Failed to get a successful response from `pro` command on {instance_name!r}.",
)

raise LXDError(
brief=f"Failed to get a successful response from `pro` command on {instance_name!r}.",
brief=f"Ubuntu Pro Client is not installed on {instance_name!r}."
)
except json.JSONDecodeError as error:
raise LXDError(
Expand Down Expand Up @@ -1249,19 +1257,30 @@ def attach_pro_subscription(
try:
payload = json.dumps({"token": pro_token, "auto_enable_services": False})

self._run_lxc(
proc = self._run_lxc(
command,
capture_output=True,
check=False,
project=project,
input=payload.encode(),
)

# No need to parse the output here, as an output with
# "result": "failure" will also have a return code != 0
# hence triggering a CalledProcesssError exception
logger.debug(
"Managed instance successfully attached to a Pro subscription."
)
if proc.returncode == 0:
logger.debug(
"Managed instance successfully attached to a Pro subscription."
)
elif proc.returncode == 1:
raise LXDError(
brief=f"Invalid token used to attach {instance_name!r} to a Pro subscription."
)
elif proc.returncode == 2:
logger.debug(
"Instance {instance_name!r} is already attached to a Pro subscription."
)
else:
raise LXDError(
brief=f"Ubuntu Pro Client is not installed on {instance_name!r}."
)
except json.JSONDecodeError as error:
raise LXDError(
brief=f"Failed to parse JSON response of `pro` command on {instance_name!r}.",
Expand Down Expand Up @@ -1301,18 +1320,25 @@ def enable_pro_service(
json.dumps({"service": service}),
]
try:
self._run_lxc(
proc = self._run_lxc(
command,
capture_output=True,
check=False,
project=project,
)

# No need to parse the output here, as an output with
# "result": "failure" will also have a return code != 0
# hence triggering a CalledProcesssError exception
logger.debug(
f"Pro service {service!r} successfully enabled on instance."
)
if proc.returncode == 0:
logger.debug(
f"Pro service {service!r} successfully enabled on instance."
)
elif proc.returncode == 1:
raise LXDError(
brief=f"Failed to enable Pro service {service!r} on unattached instance {instance_name!r}.",
)
else:
raise LXDError(
brief=f"Ubuntu Pro Client is not installed on {instance_name!r}."
)
except json.JSONDecodeError as error:
raise LXDError(
brief=f"Failed to parse JSON response of `pro` command on {instance_name!r}.",
Expand All @@ -1322,3 +1348,97 @@ def enable_pro_service(
brief=f"Failed to enable Pro service {service!r} on instance {instance_name!r}.",
details=errors.details_from_called_process_error(error),
) from error

def is_pro_installed(
self,
*,
instance_name: str,
project: str = "default",
remote: str = "local",
) -> bool:
"""Check whether Ubuntu Pro Client is installed in the instance.
:param instance_name: Name of instance.
:param project: Name of LXD project.
:param remote: Name of LXD remote.
:raises LXDError: on unexpected error.
"""
command = [
"exec",
f"{remote}:{instance_name}",
"pro",
"version",
]
try:
self._run_lxc(
command,
capture_output=True,
project=project,
)

logger.debug("Ubuntu Pro Client is installed in managed instance.")
return True # noqa: TRY300
except subprocess.CalledProcessError:
logger.debug(f"Ubuntu Pro Client is not installed on {instance_name!r}.")
return False

def install_pro_client(
self,
*,
instance_name: str,
project: str = "default",
remote: str = "local",
) -> None:
"""Install Ubuntu Pro Client in the instance.
:param instance_name: Name of instance.
:param project: Name of LXD project.
:param remote: Name of LXD remote.
:raises LXDError: on unexpected error.
"""
command = [
"exec",
f"{remote}:{instance_name}",
"--",
"apt",
"install",
"-y",
"ubuntu-advantage-tools",
]
try:
self._run_lxc(
command,
capture_output=True,
project=project,
)

if not self.is_pro_installed(
instance_name=instance_name,
project=project,
remote=remote,
):
command = [
"exec",
f"{remote}:{instance_name}",
"--",
"apt",
"install",
"-y",
"ubuntu-advantage-tools=27.11.2~$(lsb_release -rs).1",
]
self._run_lxc(
command,
capture_output=True,
project=project,
)

logger.debug(
"Ubuntu Pro Client successfully installed in managed instance."
)
except subprocess.CalledProcessError as error:
raise LXDError(
brief=f"Failed to install Ubuntu Pro Client in instance {instance_name!r}.",
details=errors.details_from_called_process_error(error),
) from error
11 changes: 11 additions & 0 deletions craft_providers/lxd/lxd_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,3 +684,14 @@ def enable_pro_service(self, services: Iterable[str]) -> None:
project=self.project,
remote=self.remote,
)

def install_pro_client(self) -> None:
"""Install Ubuntu Pro Client in the instance.
:raises: LXDError: On unexpected error.
"""
self.lxc.install_pro_client(
instance_name=self.instance_name,
project=self.project,
remote=self.remote,
)
30 changes: 10 additions & 20 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,16 @@ framework that need to provide support for additional build environments.
reference/index
explanation/index

.. grid:: 1 1 2 2

.. grid-item-card:: :ref:`Tutorial <tutorial>`

**Get started** with a hands-on introduction to Craft Providers

.. grid-item-card:: :ref:`How-to guides <howto>`

**Step-by-step guides** covering key operations and common tasks

.. grid:: 1 1 2 2
:reverse:

.. grid-item-card:: :ref:`Reference <reference>`

**Technical information** about Craft Providers

.. grid-item-card:: :ref:`Explanation <explanation>`

**Discussion and clarification** of key topics
.. list-table::

* - | :ref:`Tutorial <tutorial>`
| **Get started** with a hands-on introduction to Craft Providers
- | :ref:`How-to guides <howto>`
| **Step-by-step guides** covering key operations and common tasks
* - | :ref:`Reference <reference>`
| **Technical information** about Craft Providers
- | :ref:`Explanation <explanation>`
| **Discussion and clarification** of key topics
Project and community
=====================
Expand Down
10 changes: 5 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ dev = [
# types-requests>=2.31.0.7 requires urllib3>=2
"types-requests==2.31.0.6",
"types-setuptools==73.0.0.20240822",
"types-pyyaml==6.0.12.20240808",
"types-pyyaml==6.0.12.20240917",
]
lint = [
"black==24.8.0",
Expand All @@ -55,14 +55,14 @@ lint = [
]
types = [
"mypy[reports]==1.11.2",
"pyright==1.1.380",
"pyright==1.1.382.post1",
]
docs = [
"pyspelling==2.10",
"sphinx-autobuild==2024.9.3",
"sphinx-lint==0.9.1",
"sphinx-autobuild==2024.9.19",
"sphinx-lint==1.0.0",
"sphinx-tabs==3.4.5",
"canonical-sphinx~=0.1"
"canonical-sphinx~=0.2.0"
]

[build-system]
Expand Down
61 changes: 53 additions & 8 deletions tests/integration/lxd/test_lxc.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ def test_info(instance, lxc, session_project):
assert data["Name"] == instance


def test_is_pro_enabled_ubuntu(instance, lxc, session_project):
def test_is_pro_enabled_ubuntu_success(instance, lxc, session_project):
"""Test the scenario where Pro client is installed."""
result = lxc.is_pro_enabled(
instance_name=instance,
Expand All @@ -295,18 +295,20 @@ def test_is_pro_enabled_ubuntu(instance, lxc, session_project):
assert result is False


def test_is_pro_enabled_alma(instance_alma, lxc, session_project):
def test_is_pro_enabled_alma_failure(instance_alma, lxc, session_project):
"""Test the scenario where Pro client is not installed."""
with pytest.raises(LXDError) as raised:
lxc.is_pro_enabled(
instance_name=instance_alma,
project=session_project,
)

assert raised.value.brief == (f"Failed to run `pro` command on {instance_alma!r}.")
assert raised.value.brief == (
f"Ubuntu Pro Client is not installed on {instance_alma!r}."
)


def test_attach_pro_subscription(instance, lxc, session_project):
def test_attach_pro_subscription_success(instance, lxc, session_project):
"""Test the attachment scenario with a fake Pro token."""
with pytest.raises(LXDError) as raised:
lxc.attach_pro_subscription(
Expand All @@ -316,12 +318,26 @@ def test_attach_pro_subscription(instance, lxc, session_project):
)

assert raised.value.brief == (
f"Failed to attach {instance!r} to a Pro subscription."
f"Invalid token used to attach {instance!r} to a Pro subscription."
)


def test_enable_pro_service(instance, lxc, session_project):
"""Test the scenario where Pro client is not installed."""
def test_attach_pro_subscription_failure(instance_alma, lxc, session_project):
"""Test the attachment scenario with a fake Pro token."""
with pytest.raises(LXDError) as raised:
lxc.attach_pro_subscription(
instance_name=instance_alma,
pro_token="random", # noqa: S106
project=session_project,
)

assert raised.value.brief == (
f"Ubuntu Pro Client is not installed on {instance_alma!r}."
)


def test_enable_pro_service_success(instance, lxc, session_project):
"""Test the scenario to enable a Pro service."""
with pytest.raises(LXDError) as raised:
lxc.enable_pro_service(
instance_name=instance,
Expand All @@ -330,5 +346,34 @@ def test_enable_pro_service(instance, lxc, session_project):
)

assert raised.value.brief == (
f"Failed to enable Pro service 'esm-infra' on instance {instance!r}."
f"Failed to enable Pro service 'esm-infra' on unattached instance {instance!r}."
)


def test_enable_pro_service_failure(instance_alma, lxc, session_project):
"""Test the scenario to enable a Pro service."""
with pytest.raises(LXDError) as raised:
lxc.enable_pro_service(
instance_name=instance_alma,
services=["esm-infra"],
project=session_project,
)

assert raised.value.brief == (
f"Ubuntu Pro Client is not installed on {instance_alma!r}."
)


def test_install_pro_client(instance, lxc, session_project):
"""Test the scenario of installing the Pro Client."""
lxc.install_pro_client(
instance_name=instance,
project=session_project,
)

assert (
lxc.is_pro_installed(
instance_name=instance,
project=session_project,
)
) is True
Loading

0 comments on commit 09caf83

Please sign in to comment.