From 14d1c5cf4e262e7b818774fdbb309b899b4252a4 Mon Sep 17 00:00:00 2001 From: Grant Orndorff Date: Mon, 1 Apr 2024 21:45:11 -0400 Subject: [PATCH] api: u.pro.services.disable.v1 --- features/api_disable.feature | 189 ++++++++++++++++++ features/api_enable.feature | 4 +- uaclient/api/api.py | 1 + uaclient/api/exceptions.py | 2 + .../test_api_u_pro_services_disable_v1.py | 164 +++++++++++++++ .../api/u/pro/services/disable/__init__.py | 0 uaclient/api/u/pro/services/disable/v1.py | 127 ++++++++++++ uaclient/entitlements/esm.py | 2 +- uaclient/entitlements/repo.py | 8 +- uaclient/exceptions.py | 16 ++ uaclient/messages/__init__.py | 5 + 11 files changed, 511 insertions(+), 7 deletions(-) create mode 100644 features/api_disable.feature create mode 100644 uaclient/api/tests/test_api_u_pro_services_disable_v1.py create mode 100644 uaclient/api/u/pro/services/disable/__init__.py create mode 100644 uaclient/api/u/pro/services/disable/v1.py diff --git a/features/api_disable.feature b/features/api_disable.feature new file mode 100644 index 0000000000..6c43e008f1 --- /dev/null +++ b/features/api_disable.feature @@ -0,0 +1,189 @@ +Feature: u.pro.services.disable + + Scenario Outline: u.pro.services.disable.v1 container services + Given a `` `` machine with ubuntu-advantage-tools installed + When I apt update + # Requires attach + When I verify that running `pro api u.pro.services.disable.v1 --args service=esm-infra` `with sudo` exits `1` + Then API errors field output is: + """ + [ + { + "code": "unattached", + "meta": {}, + "title": "This machine is not attached to an Ubuntu Pro subscription.\nSee https://ubuntu.com/pro" + } + ] + """ + When I attach `contract_token` with sudo + # Requires root + When I verify that running `pro api u.pro.services.disable.v1 --args service=esm-infra` `as non-root` exits `1` + Then API errors field output is: + """ + [ + { + "code": "nonroot-user", + "meta": {}, + "title": "This command must be run as root (try using sudo)." + } + ] + """ + # Basic disable + When I run `pro api u.pro.services.disable.v1 --args service=esm-infra` with sudo + Then API data field output is: + """ + { + "attributes": { + "disabled": [ + "esm-infra" + ] + }, + "meta": { + "environment_vars": [] + }, + "type": "DisableService" + } + """ + Then I verify that `esm-infra` is disabled + # Disable already disabled service succeeds + When I run `pro api u.pro.services.disable.v1 --args service=esm-infra` with sudo + Then API data field output is: + """ + { + "attributes": { + "disabled": [] + }, + "meta": { + "environment_vars": [] + }, + "type": "DisableService" + } + """ + # disables dependent services + When I run `pro enable ros-updates --assume-yes` with sudo + When I run `pro api u.pro.services.disable.v1 --args service=esm-apps` with sudo + Then API data field output is: + """ + { + "attributes": { + "disabled": [ + "esm-apps", + "ros", + "ros-updates" + ] + }, + "meta": { + "environment_vars": [] + }, + "type": "DisableService" + } + """ + # purge works and post enable messages work + When I apt install `curl` + When I run `apt-cache policy curl` as non-root + Then stdout matches regexp: + """ + \*\*\* \+esm.* 510 + """ + When I run `pro api u.pro.services.disable.v1 --data '{"service": "esm-infra", "purge": true}'` with sudo + Then API data field output is: + """ + { + "attributes": { + "disabled": [ + "esm-infra" + ] + }, + "meta": { + "environment_vars": [] + }, + "type": "DisableService" + } + """ + When I run `apt-cache policy curl` as non-root + Then stdout contains substring: + """ + *** 500 + """ + + Examples: + | release | machine_type | curl_version | + | xenial | lxd-container | 7.47.0-1ubuntu2.19 | + | bionic | lxd-container | 7.58.0-2ubuntu3.24 | + + Scenario Outline: u.pro.services.disable.v1 vm services + Given a `` `` machine with ubuntu-advantage-tools installed + When I apt update + And I attach `contract_token` with sudo + # Basic disable + And I run `pro api u.pro.services.disable.v1 --args service=livepatch` with sudo + Then API data field output is: + """ + { + "attributes": { + "disabled": [ + "livepatch" + ] + }, + "meta": { + "environment_vars": [] + }, + "type": "DisableService" + } + """ + # fails when purge not supported + When I run `pro enable realtime-kernel --access-only` with sudo + When I verify that running `pro api u.pro.services.disable.v1 --data '{"service": "realtime-kernel", "purge": true}'` `with sudo` exits `1` + Then API errors field output is: + """ + [ + { + "code": "entitlement-not-disabled", + "meta": { + "reason": { + "additional_info": null, + "code": "disable-purge-not-supported", + "title": "Real-time kernel does not support being disabled with --purge" + } + }, + "title": "failed to disable realtime-kernel" + } + ] + """ + + Examples: + | release | machine_type | + | jammy | lxd-vm | + + Scenario Outline: u.pro.services.disable.v1 with progress + Given a `` `` machine with ubuntu-advantage-tools installed + When I run `apt-get update` with sudo + And I attach `contract_token` with sudo + # Basic disable + And I run shell command `pro api u.pro.services.disable.v1 --show-progress --args service=esm-infra` with sudo + Then stdout contains substring: + """ + {"total_steps": 2, "done_steps": 0, "previous_step_message": null, "current_step_message": "Removing APT access to Ubuntu Pro: ESM Infra"} + {"total_steps": 2, "done_steps": 1, "previous_step_message": "Removing APT access to Ubuntu Pro: ESM Infra", "current_step_message": "Updating package lists"} + {"total_steps": 2, "done_steps": 2, "previous_step_message": "Updating package lists", "current_step_message": null} + {"_schema_version": "v1", "data": {"attributes": {"disabled": ["esm-infra"]}, "meta": {"environment_vars": []}, "type": "DisableService"}, "errors": [], "result": "success" + """ + # Disabling multiple services shows steps correctly + When I run `pro enable ros-updates --assume-yes` with sudo + When I run `pro api u.pro.services.disable.v1 --show-progress --args service=esm-apps` with sudo + Then stdout contains substring: + """ + {"total_steps": 6, "done_steps": 0, "previous_step_message": null, "current_step_message": "Removing APT access to ROS ESM All Updates"} + {"total_steps": 6, "done_steps": 1, "previous_step_message": "Removing APT access to ROS ESM All Updates", "current_step_message": "Updating package lists"} + {"total_steps": 6, "done_steps": 2, "previous_step_message": "Updating package lists", "current_step_message": "Removing APT access to ROS ESM Security Updates"} + {"total_steps": 6, "done_steps": 3, "previous_step_message": "Removing APT access to ROS ESM Security Updates", "current_step_message": "Updating package lists"} + {"total_steps": 6, "done_steps": 4, "previous_step_message": "Updating package lists", "current_step_message": "Removing APT access to Ubuntu Pro: ESM Apps"} + {"total_steps": 6, "done_steps": 5, "previous_step_message": "Removing APT access to Ubuntu Pro: ESM Apps", "current_step_message": "Updating package lists"} + {"total_steps": 6, "done_steps": 6, "previous_step_message": "Updating package lists", "current_step_message": null} + {"_schema_version": "v1", "data": {"attributes": {"disabled": ["esm-apps", "ros", "ros-updates"]}, "meta": {"environment_vars": []}, "type": "DisableService"}, "errors": [], "result": "success" + """ + + Examples: + | release | machine_type | + | xenial | lxd-container | + | bionic | lxd-container | diff --git a/features/api_enable.feature b/features/api_enable.feature index 5f0781da27..4837749124 100644 --- a/features/api_enable.feature +++ b/features/api_enable.feature @@ -261,8 +261,8 @@ Feature: u.pro.services.enable When I run `pro api u.pro.services.enable.v1 --show-progress --data '{"service": "realtime-kernel", "variant": "intel-iotg"}'` with sudo Then stdout contains substring: """ - {"total_steps": 4, "done_steps": 0, "previous_step_message": null, "current_step_message": "Disabling incompatible service: Livepatch"} - {"total_steps": 4, "done_steps": 1, "previous_step_message": "Disabling incompatible service: Livepatch", "current_step_message": "Configuring APT access to Real-time Intel IOTG Kernel"} + {"total_steps": 4, "done_steps": 0, "previous_step_message": null, "current_step_message": "Executing `/snap/bin/canonical-livepatch disable`"} + {"total_steps": 4, "done_steps": 1, "previous_step_message": "Executing `/snap/bin/canonical-livepatch disable`", "current_step_message": "Configuring APT access to Real-time Intel IOTG Kernel"} {"total_steps": 4, "done_steps": 2, "previous_step_message": "Configuring APT access to Real-time Intel IOTG Kernel", "current_step_message": "Updating Real-time Intel IOTG Kernel package lists"} {"total_steps": 4, "done_steps": 3, "previous_step_message": "Updating Real-time Intel IOTG Kernel package lists", "current_step_message": "Installing Real-time Intel IOTG Kernel packages"} {"total_steps": 4, "done_steps": 4, "previous_step_message": "Installing Real-time Intel IOTG Kernel packages", "current_step_message": null} diff --git a/uaclient/api/api.py b/uaclient/api/api.py index 0624d53f7c..ea7866eb16 100644 --- a/uaclient/api/api.py +++ b/uaclient/api/api.py @@ -25,6 +25,7 @@ "u.pro.security.status.livepatch_cves.v1", "u.pro.security.status.reboot_required.v1", "u.pro.services.dependencies.v1", + "u.pro.services.disable.v1", "u.pro.services.enable.v1", "u.pro.status.enabled_services.v1", "u.pro.status.is_attached.v1", diff --git a/uaclient/api/exceptions.py b/uaclient/api/exceptions.py index 3129035b41..c1815edb7c 100644 --- a/uaclient/api/exceptions.py +++ b/uaclient/api/exceptions.py @@ -4,6 +4,7 @@ AlreadyAttachedError, ConnectivityError, ContractAPIError, + EntitlementNotDisabledError, EntitlementNotEnabledError, EntitlementNotFoundError, EntitlementsNotEnabledError, @@ -34,6 +35,7 @@ "UserFacingError", "EntitlementsNotEnabledError", "EntitlementNotEnabledError", + "EntitlementNotDisabledError", "IncompatibleServiceStopsEnable", "RequiredServiceStopsEnable", ] diff --git a/uaclient/api/tests/test_api_u_pro_services_disable_v1.py b/uaclient/api/tests/test_api_u_pro_services_disable_v1.py new file mode 100644 index 0000000000..e366bea046 --- /dev/null +++ b/uaclient/api/tests/test_api_u_pro_services_disable_v1.py @@ -0,0 +1,164 @@ +import mock +import pytest + +from uaclient.api import exceptions +from uaclient.api.u.pro.services.disable.v1 import ( + DisableOptions, + DisableResult, + _disable, +) +from uaclient.testing.helpers import does_not_raise + +M_PATH = "uaclient.api.u.pro.services.disable.v1." + + +class TestDisable: + @pytest.mark.parametrize( + [ + "options", + "we_are_currently_root", + "is_attached", + "enabled_services_names_before", + "enabled_services_names_after", + "disable_result", + "expected_raises", + "expected_result", + ], + [ + # not root + ( + DisableOptions(service="s1"), + False, + False, + None, + None, + None, + pytest.raises(exceptions.NonRootUserError), + None, + ), + # not attached + ( + DisableOptions(service="s1"), + True, + False, + None, + None, + None, + pytest.raises(exceptions.UnattachedError), + None, + ), + # generic disable failure + ( + DisableOptions(service="s1"), + True, + True, + ["s1"], + None, + (False, None), + pytest.raises(exceptions.EntitlementNotDisabledError), + None, + ), + # success + ( + DisableOptions(service="s1"), + True, + True, + ["s1"], + [], + (True, None), + does_not_raise(), + DisableResult( + disabled=["s1"], + ), + ), + # success already disabled + ( + DisableOptions(service="s1"), + True, + True, + [], + None, + None, + does_not_raise(), + DisableResult( + disabled=[], + ), + ), + # success with additional disablements + ( + DisableOptions(service="s1"), + True, + True, + ["s1", "s2", "s3"], + ["s2"], + (True, None), + does_not_raise(), + DisableResult( + disabled=["s1", "s3"], + ), + ), + ], + ) + @mock.patch(M_PATH + "lock.clear_lock_file_if_present") + @mock.patch(M_PATH + "lock.RetryLock") + @mock.patch(M_PATH + "entitlements.entitlement_factory") + @mock.patch(M_PATH + "_enabled_services_names") + @mock.patch(M_PATH + "_is_attached") + @mock.patch(M_PATH + "util.we_are_currently_root") + def test_disable( + self, + m_we_are_currently_root, + m_is_attached, + m_enabled_services_names, + m_entitlement_factory, + m_spin_lock, + m_clear_lock_file_if_present, + options, + we_are_currently_root, + is_attached, + enabled_services_names_before, + enabled_services_names_after, + disable_result, + expected_raises, + expected_result, + FakeConfig, + ): + m_we_are_currently_root.return_value = we_are_currently_root + m_is_attached.return_value = mock.MagicMock(is_attached=is_attached) + m_enabled_services_names.side_effect = [ + enabled_services_names_before, + enabled_services_names_after, + ] + m_ent_class = m_entitlement_factory.return_value + m_ent = m_ent_class.return_value + m_ent_variant = m_ent.enabled_variant + m_ent_variant.disable.return_value = disable_result + + cfg = FakeConfig() + + actual_result = None + with expected_raises: + actual_result = _disable( + options, cfg, progress_object=mock.MagicMock() + ) + + assert actual_result == expected_result + + if expected_result is not None and len(expected_result.disabled) > 0: + assert m_entitlement_factory.call_args_list == [ + mock.call( + cfg=cfg, + name=options.service, + ) + ] + assert m_ent_class.call_args_list == [ + mock.call( + cfg, + assume_yes=True, + called_name=options.service, + purge=options.purge, + ) + ] + assert m_ent_variant.disable.call_args_list == [ + mock.call(mock.ANY) + ] diff --git a/uaclient/api/u/pro/services/disable/__init__.py b/uaclient/api/u/pro/services/disable/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/uaclient/api/u/pro/services/disable/v1.py b/uaclient/api/u/pro/services/disable/v1.py new file mode 100644 index 0000000000..d71a8518f2 --- /dev/null +++ b/uaclient/api/u/pro/services/disable/v1.py @@ -0,0 +1,127 @@ +import logging +from typing import List, Optional + +from uaclient import entitlements, lock, messages, util +from uaclient.api import AbstractProgress, ProgressWrapper, exceptions +from uaclient.api.api import APIEndpoint +from uaclient.api.data_types import AdditionalInfo +from uaclient.api.u.pro.status.enabled_services.v1 import _enabled_services +from uaclient.api.u.pro.status.is_attached.v1 import _is_attached +from uaclient.config import UAConfig +from uaclient.data_types import ( + BoolDataValue, + DataObject, + Field, + StringDataValue, + data_list, +) + +LOG = logging.getLogger(util.replace_top_level_logger_name(__name__)) + + +class DisableOptions(DataObject): + fields = [ + Field("service", StringDataValue), + Field("purge", BoolDataValue, False), + ] + + def __init__(self, *, service: str, purge: bool = False): + self.service = service + self.purge = purge + + +class DisableResult(DataObject, AdditionalInfo): + fields = [ + Field("disabled", data_list(StringDataValue)), + ] + + def __init__(self, *, disabled: List[str]): + self.disabled = disabled + + +def _enabled_services_names(cfg: UAConfig) -> List[str]: + return [s.name for s in _enabled_services(cfg).enabled_services] + + +def disable( + options: DisableOptions, progress_object: Optional[AbstractProgress] = None +) -> DisableResult: + return _disable(options, UAConfig(), progress_object=progress_object) + + +def _disable( + options: DisableOptions, + cfg: UAConfig, + progress_object: Optional[AbstractProgress] = None, +) -> DisableResult: + progress = ProgressWrapper(progress_object) + + if not util.we_are_currently_root(): + raise exceptions.NonRootUserError() + + if not _is_attached(cfg).is_attached: + raise exceptions.UnattachedError() + + enabled_services_before = _enabled_services_names(cfg) + if options.service not in enabled_services_before: + # nothing to do + return DisableResult( + disabled=[], + ) + + ent_cls = entitlements.entitlement_factory(cfg=cfg, name=options.service) + entitlement = ent_cls( + cfg, + assume_yes=True, + called_name=options.service, + purge=options.purge, + ) + variant = entitlement.enabled_variant + if variant is not None: + entitlement = variant + + progress.total_steps = entitlement.calculate_total_disable_steps() + + success = False + fail_reason = None + + try: + with lock.RetryLock( + lock_holder="u.pro.services.disable.v1", + ): + success, fail_reason = entitlement.disable(progress) + except Exception as e: + lock.clear_lock_file_if_present() + raise e + + if not success: + if fail_reason is not None and fail_reason.message is not None: + reason = fail_reason.message + else: + reason = messages.GENERIC_UNKNOWN_ISSUE + raise exceptions.EntitlementNotDisabledError( + service=options.service, reason=reason + ) + + enabled_services_after = _enabled_services_names(cfg) + + progress.finish() + + return DisableResult( + disabled=sorted( + list( + set(enabled_services_before).difference( + set(enabled_services_after) + ) + ) + ), + ) + + +endpoint = APIEndpoint( + version="v1", + name="DisableService", + fn=_disable, + options_cls=DisableOptions, + supports_progress=True, +) diff --git a/uaclient/entitlements/esm.py b/uaclient/entitlements/esm.py index 9fb114f4d3..a27ce63772 100644 --- a/uaclient/entitlements/esm.py +++ b/uaclient/entitlements/esm.py @@ -20,7 +20,7 @@ def dependent_services(self) -> Tuple[Type[UAEntitlement], ...]: ROSUpdatesEntitlement, ) - return (ROSEntitlement, ROSUpdatesEntitlement) + return (ROSUpdatesEntitlement, ROSEntitlement) def _perform_enable(self, progress: api.ProgressWrapper) -> bool: from uaclient.timer.update_messaging import update_motd_messages diff --git a/uaclient/entitlements/repo.py b/uaclient/entitlements/repo.py index cdec616fd8..f2ac727780 100644 --- a/uaclient/entitlements/repo.py +++ b/uaclient/entitlements/repo.py @@ -169,13 +169,13 @@ def _perform_enable(self, progress: api.ProgressWrapper) -> bool: return True def disable_steps(self) -> int: - if self.purge: + if not self.purge: # 1. Unconfigure APT - # 2. Purge + # 2. Update package lists return 2 else: - # 1. Unconfigure APT - return 1 + # 3. Purge + return 3 def _perform_disable(self, progress: api.ProgressWrapper): if self.purge and self.origin: diff --git a/uaclient/exceptions.py b/uaclient/exceptions.py index 4f6b8390b6..6447a82932 100644 --- a/uaclient/exceptions.py +++ b/uaclient/exceptions.py @@ -366,6 +366,22 @@ def __init__(self, service: str, reason: messages.NamedMessage): ) +class EntitlementNotDisabledError(UbuntuProError): + _formatted_msg = messages.E_ENTITLEMENT_NOT_DISABLED_ERROR + + def __init__(self, service: str, reason: messages.NamedMessage): + super().__init__( + service=service, + additional_info={ + "reason": { + "code": reason.name, + "title": reason.msg, + "additional_info": reason.additional_info, + } + }, + ) + + class AttachFailureDefaultServices(EntitlementsNotEnabledError): _msg = messages.E_ATTACH_FAILURE_DEFAULT_SERVICES diff --git a/uaclient/messages/__init__.py b/uaclient/messages/__init__.py index e3bc1e5011..4c026c508a 100644 --- a/uaclient/messages/__init__.py +++ b/uaclient/messages/__init__.py @@ -2215,6 +2215,11 @@ def __repr__(self): t.gettext("failed to enable {service}"), ) +E_ENTITLEMENT_NOT_DISABLED_ERROR = FormattedNamedMessage( + "entitlement-not-disabled", + t.gettext("failed to disable {service}"), +) + E_ATTACH_FAILURE_DEFAULT_SERVICES = NamedMessage( "attach-failure-default-service", t.gettext(