diff --git a/features/livepatch.feature b/features/livepatch.feature index fa7347e398..20292da073 100644 --- a/features/livepatch.feature +++ b/features/livepatch.feature @@ -197,3 +197,41 @@ Feature: Livepatch Examples: ubuntu release | release | machine_type | release_num | | jammy | lxd-vm | 22.04 | + + @series.xenial + @uses.config.machine_type.any + @uses.config.machine_type.lxd.vm + Scenario Outline: snapd installed as a snap if necessary + Given a `` `` machine with ubuntu-advantage-tools installed + When I run `snap list` with sudo + Then stdout does not contain substring: + """ + snapd + """ + When I set the machine token overlay to the following yaml + """ + machineTokenInfo: + contractInfo: + resourceEntitlements: + - type: livepatch + directives: + requiredSnaps: + - name: core22 + """ + When I attach `contract_token` with sudo + Then stdout contains substring: + """ + Installing snapd snap + """ + When I run `snap list` with sudo + Then stdout contains substring: + """ + snapd + """ + And stdout contains substring: + """ + core22 + """ + Examples: ubuntu release + | release | machine_type | + | xenial | lxd-vm | diff --git a/uaclient/entitlements/base.py b/uaclient/entitlements/base.py index 51882dd6d9..2a31c159ec 100644 --- a/uaclient/entitlements/base.py +++ b/uaclient/entitlements/base.py @@ -495,6 +495,20 @@ def handle_required_snaps(self) -> bool: event.info(messages.INSTALLING_PACKAGES.format(packages="snapd")) snap.install_snapd() + if not snap.is_snapd_installed_as_a_snap(): + event.info( + messages.INSTALLING_PACKAGES.format(packages="snapd snap") + ) + try: + snap.install_snap("snapd") + except exceptions.ProcessExecutionError as e: + LOG.warning("Failed to install snapd as a snap", exc_info=e) + event.info( + messages.EXECUTING_COMMAND_FAILED.format( + command="snap install snapd" + ) + ) + snap.run_snapd_wait_cmd() http_proxy = http.validate_proxy( diff --git a/uaclient/entitlements/livepatch.py b/uaclient/entitlements/livepatch.py index 96b313f09a..54a88b4878 100644 --- a/uaclient/entitlements/livepatch.py +++ b/uaclient/entitlements/livepatch.py @@ -90,6 +90,20 @@ def _perform_enable(self, silent: bool = False) -> bool: event.info(messages.INSTALLING_PACKAGES.format(packages="snapd")) snap.install_snapd() + if not snap.is_snapd_installed_as_a_snap(): + event.info( + messages.INSTALLING_PACKAGES.format(packages="snapd snap") + ) + try: + snap.install_snap("snapd") + except exceptions.ProcessExecutionError as e: + LOG.warning("Failed to install snapd as a snap", exc_info=e) + event.info( + messages.EXECUTING_COMMAND_FAILED.format( + command="snap install snapd" + ) + ) + snap.run_snapd_wait_cmd() http_proxy = http.validate_proxy( diff --git a/uaclient/entitlements/tests/test_base.py b/uaclient/entitlements/tests/test_base.py index b664bc0396..dbb7d52bf2 100644 --- a/uaclient/entitlements/tests/test_base.py +++ b/uaclient/entitlements/tests/test_base.py @@ -1275,6 +1275,9 @@ class TestHandleRequiredSnaps: @mock.patch( "uaclient.entitlements.base.UAEntitlement._base_entitlement_cfg" ) + @mock.patch( + "uaclient.snap.is_snapd_installed_as_a_snap", return_value=True + ) @mock.patch("uaclient.snap.is_snapd_installed", return_value=True) @mock.patch("uaclient.snap.run_snapd_wait_cmd") @mock.patch("uaclient.snap.get_snap_info") @@ -1289,6 +1292,7 @@ def test_handle_required_snaps( m_get_snap_info, m_run_snapd_wait_cmd, m_is_snapd_installed, + m_is_snapd_installed_as_a_snap, m_base_ent_cfg, entitlement_cfg, concrete_entitlement_factory, @@ -1305,11 +1309,13 @@ def test_handle_required_snaps( if not entitlement_cfg: assert 0 == m_is_snapd_installed.call_count + assert 0 == m_is_snapd_installed_as_a_snap.call_count assert 0 == m_run_snapd_wait_cmd.call_count assert 0 == m_validate_proxy.call_count assert 0 == m_configure_snap_proxy.call_count else: assert 1 == m_is_snapd_installed.call_count + assert 1 == m_is_snapd_installed_as_a_snap.call_count assert 1 == m_run_snapd_wait_cmd.call_count assert 2 == m_validate_proxy.call_count assert 1 == m_configure_snap_proxy.call_count diff --git a/uaclient/entitlements/tests/test_livepatch.py b/uaclient/entitlements/tests/test_livepatch.py index 340b986360..9fa271f6af 100644 --- a/uaclient/entitlements/tests/test_livepatch.py +++ b/uaclient/entitlements/tests/test_livepatch.py @@ -372,6 +372,7 @@ def test_livepatch_disable_and_setup_performed_when_resource_token_changes( assert setup_calls == m_setup_livepatch_config.call_args_list +@mock.patch(M_PATH + "snap.is_snapd_installed_as_a_snap") @mock.patch(M_PATH + "snap.is_snapd_installed") @mock.patch("uaclient.http.validate_proxy", side_effect=lambda x, y, z: y) @mock.patch("uaclient.snap.configure_snap_proxy") @@ -385,6 +386,13 @@ class TestLivepatchEntitlementEnable: retry_sleeps=apt.APT_RETRIES, ) ] + mocks_snapd_install_as_a_snap = [ + mock.call( + ["/usr/bin/snap", "install", "snapd"], + capture=True, + retry_sleeps=[0.5, 1, 5], + ) + ] mocks_snap_wait_seed = [ mock.call( ["/usr/bin/snap", "wait", "system", "seed.loaded"], capture=True @@ -398,7 +406,10 @@ class TestLivepatchEntitlementEnable: ) ] mocks_install = ( - mocks_snapd_install + mocks_snap_wait_seed + mocks_livepatch_install + mocks_snapd_install + + mocks_snapd_install_as_a_snap + + mocks_snap_wait_seed + + mocks_livepatch_install ) mocks_config = [ mock.call( @@ -444,6 +455,7 @@ def test_enable_installs_snapd_and_livepatch_snap_when_absent( m_snap_proxy, m_validate_proxy, m_is_snapd_installed, + m_is_snapd_installed_as_a_snap, capsys, caplog_text, event, @@ -454,6 +466,7 @@ def test_enable_installs_snapd_and_livepatch_snap_when_absent( application_status = ApplicationStatus.ENABLED m_app_status.return_value = application_status, "enabled" m_is_snapd_installed.return_value = False + m_is_snapd_installed_as_a_snap.return_value = False def fake_update_sources_list(sources_list): if apt_update_success: @@ -469,6 +482,7 @@ def fake_update_sources_list(sources_list): msg = ( "Installing snapd\n" "Updating standard Ubuntu package lists\n" + "Installing snapd snap\n" "Installing canonical-livepatch snap\n" "Disabling Livepatch prior to re-attach with new token\n" "Canonical Livepatch enabled\n" @@ -487,6 +501,73 @@ def fake_update_sources_list(sources_list): assert m_snap_proxy.call_count == 1 assert m_livepatch_proxy.call_count == 1 + @pytest.mark.parametrize("caplog_text", [logging.DEBUG], indirect=True) + @mock.patch("uaclient.system.get_release_info") + @mock.patch("uaclient.system.subp") + @mock.patch("uaclient.contract.apply_contract_overrides") + @mock.patch("uaclient.apt.update_sources_list") + @mock.patch("uaclient.apt.run_apt_install_command") + @mock.patch("uaclient.apt.run_apt_update_command") + @mock.patch("uaclient.system.which", return_value=None) + @mock.patch(M_PATH + "LivepatchEntitlement.application_status") + @mock.patch( + M_PATH + "LivepatchEntitlement.can_enable", return_value=(True, None) + ) + def test_enable_continues_when_snap_install_snapd_fails( + self, + m_can_enable, + m_app_status, + m_which, + m_run_apt_update, + m_run_apt_install, + m_update_sources_list, + _m_contract_overrides, + m_subp, + _m_get_release_info, + m_livepatch_proxy, + m_snap_proxy, + m_validate_proxy, + m_is_snapd_installed, + m_is_snapd_installed_as_a_snap, + capsys, + caplog_text, + event, + entitlement, + ): + """Install snapd and canonical-livepatch snap when not on system.""" + application_status = ApplicationStatus.ENABLED + m_app_status.return_value = application_status, "enabled" + m_is_snapd_installed.return_value = False + m_is_snapd_installed_as_a_snap.return_value = False + m_subp.side_effect = [ + None, + exceptions.ProcessExecutionError("test"), + None, + None, + None, + None, + None, + ] + + assert entitlement.enable() + assert self.mocks_install + self.mocks_config in m_subp.call_args_list + assert self.mocks_apt_update == m_run_apt_update.call_args_list + assert 1 == m_update_sources_list.call_count + msg = ( + "Installing snapd\n" + "Updating standard Ubuntu package lists\n" + "Installing snapd snap\n" + "Executing `snap install snapd` failed.\n" + "Installing canonical-livepatch snap\n" + "Disabling Livepatch prior to re-attach with new token\n" + "Canonical Livepatch enabled\n" + ) + assert (msg, "") == capsys.readouterr() + assert [mock.call(livepatch.LIVEPATCH_CMD)] == m_which.call_args_list + assert m_validate_proxy.call_count == 2 + assert m_snap_proxy.call_count == 1 + assert m_livepatch_proxy.call_count == 1 + @mock.patch("uaclient.system.get_release_info") @mock.patch("uaclient.system.subp") @mock.patch("uaclient.contract.apply_contract_overrides") @@ -507,6 +588,7 @@ def test_enable_installs_only_livepatch_snap_when_absent_but_snapd_present( m_snap_proxy, m_validate_proxy, m_is_snapd_installed, + m_is_snapd_installed_as_a_snap, capsys, event, entitlement, @@ -556,6 +638,7 @@ def test_enable_does_not_install_livepatch_snap_when_present( m_snap_proxy, m_validate_proxy, m_is_snapd_installed, + m_is_snapd_installed_as_a_snap, capsys, event, entitlement, @@ -616,6 +699,7 @@ def test_enable_does_not_disable_inactive_livepatch_snap_when_present( m_snap_proxy, m_validate_proxy, m_is_snapd_installed, + m_is_snapd_installed_as_a_snap, capsys, entitlement, ): @@ -660,6 +744,7 @@ def test_enable_fails_when_blocking_service_is_enabled( m_snap_proxy, m_validate_proxy, _m_is_snapd_installed, + m_is_snapd_installed_as_a_snap, cls_name, cls_title, entitlement, @@ -696,6 +781,7 @@ def test_enable_alerts_user_that_snapd_does_not_wait_command( m_snap_proxy, m_validate_proxy, m_is_snapd_installed, + m_is_snapd_installed_as_a_snap, entitlement, capsys, caplog_text, @@ -759,6 +845,7 @@ def test_enable_raise_exception_when_snapd_cant_be_installed( m_snap_proxy, m_validate_proxy, m_is_snapd_installed, + m_is_snapd_installed_as_a_snap, entitlement, ): m_is_snapd_installed.return_value = False @@ -794,6 +881,7 @@ def test_enable_raise_exception_for_unexpected_error_on_snapd_wait( m_snap_proxy, m_validate_proxy, m_is_snapd_installed, + m_is_snapd_installed_as_a_snap, entitlement, ): m_is_snapd_installed.return_value = True diff --git a/uaclient/snap.py b/uaclient/snap.py index 536ac9d723..eafa2f7c3e 100644 --- a/uaclient/snap.py +++ b/uaclient/snap.py @@ -35,6 +35,11 @@ def is_snapd_installed() -> bool: return "snapd" in apt.get_installed_packages_names() +def is_snapd_installed_as_a_snap() -> bool: + """Returns whether or not snapd is installed as a snap""" + return any((snap.name == "snapd" for snap in get_installed_snaps())) + + def configure_snap_proxy( http_proxy: Optional[str] = None, https_proxy: Optional[str] = None, diff --git a/uaclient/tests/test_snap.py b/uaclient/tests/test_snap.py index 4bc81d6dcb..e8551d430d 100644 --- a/uaclient/tests/test_snap.py +++ b/uaclient/tests/test_snap.py @@ -10,10 +10,53 @@ get_config_option_value, get_installed_snaps, get_snap_info, + is_snapd_installed_as_a_snap, unconfigure_snap_proxy, ) +class TestIsSnapdInstalledAsASnap: + @pytest.mark.parametrize( + ["installed_snaps", "expected"], + [ + ([], False), + ( + [ + SnapPackage( + "one", "123", "456", "oldest/unstable", "someone" + ) + ], + False, + ), + ( + [ + SnapPackage( + "snapd", "123", "456", "oldest/unstable", "someone" + ) + ], + True, + ), + ( + [ + SnapPackage( + "one", "123", "456", "oldest/unstable", "someone" + ), + SnapPackage( + "snapd", "123", "456", "oldest/unstable", "someone" + ), + ], + True, + ), + ], + ) + @mock.patch("uaclient.snap.get_installed_snaps") + def test_is_snapd_installed_as_a_snap( + self, m_get_installed_snaps, installed_snaps, expected + ): + m_get_installed_snaps.return_value = installed_snaps + assert expected == is_snapd_installed_as_a_snap() + + class TestConfigureSnapProxy: @pytest.mark.parametrize("http_proxy", ("http_proxy", "", None)) @pytest.mark.parametrize("https_proxy", ("https_proxy", "", None))