diff --git a/craft_providers/lxd/lxc.py b/craft_providers/lxd/lxc.py index c25ffac3..59466db8 100644 --- a/craft_providers/lxd/lxc.py +++ b/craft_providers/lxd/lxc.py @@ -16,6 +16,7 @@ # """LXC wrapper.""" + import contextlib import enum import json @@ -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( @@ -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}.", @@ -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}.", @@ -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 diff --git a/craft_providers/lxd/lxd_instance.py b/craft_providers/lxd/lxd_instance.py index 94eb3a14..d9874a42 100644 --- a/craft_providers/lxd/lxd_instance.py +++ b/craft_providers/lxd/lxd_instance.py @@ -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, + ) diff --git a/docs/index.rst b/docs/index.rst index 6c52e261..ddaf7e63 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 ` - - **Get started** with a hands-on introduction to Craft Providers - - .. grid-item-card:: :ref:`How-to guides ` - - **Step-by-step guides** covering key operations and common tasks - -.. grid:: 1 1 2 2 - :reverse: - - .. grid-item-card:: :ref:`Reference ` - - **Technical information** about Craft Providers - - .. grid-item-card:: :ref:`Explanation ` - - **Discussion and clarification** of key topics +.. list-table:: + + * - | :ref:`Tutorial ` + | **Get started** with a hands-on introduction to Craft Providers + - | :ref:`How-to guides ` + | **Step-by-step guides** covering key operations and common tasks + * - | :ref:`Reference ` + | **Technical information** about Craft Providers + - | :ref:`Explanation ` + | **Discussion and clarification** of key topics Project and community ===================== diff --git a/pyproject.toml b/pyproject.toml index c3a5cc1b..8f575649 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -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] diff --git a/tests/integration/lxd/test_lxc.py b/tests/integration/lxd/test_lxc.py index e12a58ef..c7fbc21d 100644 --- a/tests/integration/lxd/test_lxc.py +++ b/tests/integration/lxd/test_lxc.py @@ -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, @@ -295,7 +295,7 @@ 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( @@ -303,10 +303,12 @@ def test_is_pro_enabled_alma(instance_alma, lxc, session_project): 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( @@ -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, @@ -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 diff --git a/tests/unit/lxd/test_lxc.py b/tests/unit/lxd/test_lxc.py index 3ec0bcbf..e419c70d 100644 --- a/tests/unit/lxd/test_lxc.py +++ b/tests/unit/lxd/test_lxc.py @@ -2581,7 +2581,7 @@ def test_yaml_loader_invalid_timestamp(): assert isinstance(obj["last_used_at"], str) -def test_is_pro_enabled_success(fake_process): +def test_is_pro_enabled_success_true(fake_process): fake_process.register_subprocess( [ "lxc", @@ -2608,6 +2608,33 @@ def test_is_pro_enabled_success(fake_process): assert len(fake_process.calls) == 1 +def test_is_pro_enabled_success_false(fake_process): + fake_process.register_subprocess( + [ + "lxc", + "--project", + "test-project", + "exec", + "test-remote:test-instance", + "pro", + "api", + "u.pro.status.is_attached.v1", + ], + stdout=b"""{"_schema_version": "v1", "data": {"attributes": {"contract_remaining_days": 2912917, "contract_status": "active", "is_attached": false, "is_attached_and_contract_valid": false}, "meta": {"environment_vars": []}, "type": "IsAttached"}, "errors": [], "result": "success", "version": "32.3.1~24.04", "warnings": []}""", + ) + + assert ( + LXC().is_pro_enabled( + instance_name="test-instance", + project="test-project", + remote="test-remote", + ) + is False + ) + + assert len(fake_process.calls) == 1 + + def test_is_pro_enabled_failed(fake_process): fake_process.register_subprocess( [ @@ -2631,8 +2658,9 @@ def test_is_pro_enabled_failed(fake_process): remote="test-remote", ) - assert exc_info.value == LXDError( - brief="Failed to get a successful response from `pro` command on 'test-instance'.", + assert ( + exc_info.value.brief + == "Failed to get a successful response from `pro` command on 'test-instance'." ) assert len(fake_process.calls) == 1 @@ -2689,11 +2717,8 @@ def test_is_pro_enabled_process_error(fake_process): remote="test-remote", ) - assert exc_info.value == LXDError( - brief="Failed to run `pro` command on 'test-instance'.", - details=errors.details_from_called_process_error( - exc_info.value.__cause__ # type: ignore - ), + assert ( + exc_info.value.brief == "Ubuntu Pro Client is not installed on 'test-instance'." ) assert len(fake_process.calls) == 1 @@ -2759,9 +2784,40 @@ def test_attach_pro_subscription_failed(fake_process): assert ( exc_info.value.brief - == "Failed to attach 'test-instance' to a Pro subscription." + == "Invalid token used to attach 'test-instance' to a Pro subscription." + ) + + assert len(fake_process.calls) == 1 + + +def test_attach_pro_subscription_already_attached(fake_process): + fake_process.register_subprocess( + [ + "lxc", + "--project", + "test-project", + "exec", + "test-remote:test-instance", + "--", + "pro", + "api", + "u.pro.attach.token.full_token_attach.v1", + "--data", + "-", + ], + stdout=b"""{"_schema_version": "v1", "data": {"attributes": {"enabled": [], "reboot_required": false}, "meta": {"environment_vars": []}, "type": "FullTokenAttach"}, "errors": ["Already attached"], "result": "failure", "version": "32.3.1~24.04", "warnings": []}""", + returncode=2, ) + assert ( + LXC().attach_pro_subscription( + instance_name="test-instance", + pro_token="random", # noqa: S106 + project="test-project", + remote="test-remote", + ) + ) is None + assert len(fake_process.calls) == 1 @@ -2791,11 +2847,8 @@ def test_attach_pro_subscription_process_error(fake_process): remote="test-remote", ) - assert exc_info.value == LXDError( - brief="Failed to attach 'test-instance' to a Pro subscription.", - details=errors.details_from_called_process_error( - exc_info.value.__cause__ # type: ignore - ), + assert ( + exc_info.value.brief == "Ubuntu Pro Client is not installed on 'test-instance'." ) assert len(fake_process.calls) == 1 @@ -2877,7 +2930,7 @@ def test_enable_pro_service_failed(fake_process): assert ( exc_info.value.brief - == "Failed to enable Pro service 'invalid' on instance 'test-instance'." + == "Failed to enable Pro service 'invalid' on unattached instance 'test-instance'." ) assert len(fake_process.calls) == 1 @@ -2909,11 +2962,202 @@ def test_enable_pro_service_process_error(fake_process): remote="test-remote", ) - assert exc_info.value == LXDError( - brief="Failed to enable Pro service 'esm-infra' on instance 'test-instance'.", - details=errors.details_from_called_process_error( - exc_info.value.__cause__ # type: ignore - ), + assert ( + exc_info.value.brief == "Ubuntu Pro Client is not installed on 'test-instance'." + ) + + assert len(fake_process.calls) == 1 + + +def test_is_pro_installed_success(fake_process): + fake_process.register_subprocess( + [ + "lxc", + "--project", + "test-project", + "exec", + "test-remote:test-instance", + "pro", + "version", + ], + stdout="32.3.1~22.04", + ) + + assert ( + LXC().is_pro_installed( + instance_name="test-instance", + project="test-project", + remote="test-remote", + ) + is True + ) + + assert len(fake_process.calls) == 1 + + +def test_is_pro_installed_failure(fake_process): + fake_process.register_subprocess( + [ + "lxc", + "--project", + "test-project", + "exec", + "test-remote:test-instance", + "pro", + "version", + ], + returncode=127, + ) + + assert ( + LXC().is_pro_installed( + instance_name="test-instance", + project="test-project", + remote="test-remote", + ) + is False + ) + + assert len(fake_process.calls) == 1 + + +def test_install_pro_client_success1(fake_process): + fake_process.register_subprocess( + [ + "lxc", + "--project", + "test-project", + "exec", + "test-remote:test-instance", + "--", + "apt", + "install", + "-y", + "ubuntu-advantage-tools", + ], + stdout="placeholder", + ) + + fake_process.register_subprocess( + [ + "lxc", + "--project", + "test-project", + "exec", + "test-remote:test-instance", + "pro", + "version", + ], + stdout="32.3.1~22.04", + ) + + assert ( + LXC().install_pro_client( + instance_name="test-instance", + project="test-project", + remote="test-remote", + ) + is None + ) + + assert len(fake_process.calls) == 2 + + +def test_install_pro_client_success2(fake_process): + fake_process.register_subprocess( + [ + "lxc", + "--project", + "test-project", + "exec", + "test-remote:test-instance", + "--", + "apt", + "install", + "-y", + "ubuntu-advantage-tools", + ], + stdout="placeholder", + ) + + fake_process.register_subprocess( + [ + "lxc", + "--project", + "test-project", + "exec", + "test-remote:test-instance", + "pro", + "version", + ], + returncode=127, + ) + + fake_process.register_subprocess( + [ + "lxc", + "--project", + "test-project", + "exec", + "test-remote:test-instance", + "--", + "apt", + "install", + "-y", + "ubuntu-advantage-tools=27.11.2~$(lsb_release -rs).1", + ], + stdout="placeholder", + ) + + assert ( + LXC().install_pro_client( + instance_name="test-instance", + project="test-project", + remote="test-remote", + ) + is None + ) + + assert len(fake_process.calls) == 3 + + +def test_install_pro_client_process_error(fake_process): + fake_process.register_subprocess( + [ + "lxc", + "--project", + "test-project", + "exec", + "test-remote:test-instance", + "--", + "apt", + "install", + "-y", + "ubuntu-advantage-tools", + ], + returncode=99, + ) + + fake_process.register_subprocess( + [ + "lxc", + "--project", + "test-project", + "exec", + "test-remote:test-instance", + "pro", + "version", + ], + returncode=127, + ) + + assert ( + LXC().is_pro_installed( + instance_name="test-instance", + project="test-project", + remote="test-remote", + ) + is False ) assert len(fake_process.calls) == 1 diff --git a/tests/unit/lxd/test_lxd_instance.py b/tests/unit/lxd/test_lxd_instance.py index 654a1199..ffe1d6ed 100644 --- a/tests/unit/lxd/test_lxd_instance.py +++ b/tests/unit/lxd/test_lxd_instance.py @@ -1075,3 +1075,15 @@ def test_enable_pro_service(mock_lxc, instance): remote=instance.remote, ) ] + + +def test_install_pro_client(mock_lxc, instance): + instance.install_pro_client() + + assert mock_lxc.mock_calls == [ + mock.call.install_pro_client( + instance_name=instance.instance_name, + project=instance.project, + remote=instance.remote, + ) + ]