Skip to content

Commit

Permalink
fix: Fix breaking changes in package install
Browse files Browse the repository at this point in the history
Ensure:
* Apt can still install packages that contain `-`, `/`, or `=`
* If a specified package manager fails we don't retry as a generic
  package
* We do not attempt to invoke a package manager that doesn't exist
* Packages that are unable to be installed are tracked and
  reported appropriately

Fixes GH-5066
  • Loading branch information
TheRealFalcon committed Mar 19, 2024
1 parent e517f5a commit 5ce8d76
Show file tree
Hide file tree
Showing 7 changed files with 196 additions and 19 deletions.
4 changes: 3 additions & 1 deletion cloudinit/config/cc_package_update_upgrade_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None:
try:
cloud.distro.install_packages(pkglist)
except Exception as e:
util.logexc(LOG, "Failed to install packages: %s", pkglist)
util.logexc(
LOG, "Failure when attempting to install packages: %s", pkglist
)
errors.append(e)

# TODO(smoser): handle this less violently
Expand Down
29 changes: 19 additions & 10 deletions cloudinit/distros/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,29 +239,38 @@ def install_packages(self, pkglist: PackageList):

# First install packages using package manager(s)
# supported by the distro
uninstalled = []
uninstalled = set()
for manager in self.package_managers:
to_try = (
packages_by_manager.get(manager.__class__, set())
| generic_packages

manager_packages = packages_by_manager.get(
manager.__class__, set()
)

to_try = manager_packages | generic_packages
if not manager.available():
LOG.debug("Package manager '%s' not available", manager.name)
uninstalled.update(to_try)
continue
if not to_try:
continue
uninstalled = manager.install_packages(to_try)
failed = {
pkg for pkg in uninstalled if pkg not in generic_packages
failed = manager.install_packages(to_try)
uninstalled.update(failed)
unexpected_failed = {
pkg for pkg in failed if pkg not in generic_packages
}
if failed:
if unexpected_failed:
LOG.info(error_message, failed)
generic_packages = set(uninstalled)
# Ensure we don't attempt to install packages specific to
# one particular package manager using another package manager
generic_packages = set(failed) - manager_packages

# Now attempt any specified package managers not explicitly supported
# by distro
for manager_type, packages in packages_by_manager.items():
if manager_type.name in [p.name for p in self.package_managers]:
# We already installed/attempted these; don't try again
continue
uninstalled.extend(
uninstalled.update(
manager_type.from_config(
self._runner, self._cfg
).install_packages(pkglist=packages)
Expand Down
30 changes: 22 additions & 8 deletions cloudinit/distros/package_management/apt.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import functools
import logging
import os
import re
import time
from typing import Any, Iterable, List, Mapping, Optional, Sequence, cast

Expand Down Expand Up @@ -83,11 +84,11 @@ def __init__(
):
super().__init__(runner)
if apt_get_command is None:
apt_get_command = APT_GET_COMMAND
self.apt_get_command = APT_GET_COMMAND
if apt_get_upgrade_subcommand is None:
apt_get_upgrade_subcommand = "dist-upgrade"
self.apt_command = tuple(apt_get_wrapper_command) + tuple(
apt_get_command
self.apt_get_command
)

self.apt_get_upgrade_subcommand = apt_get_upgrade_subcommand
Expand All @@ -104,6 +105,9 @@ def from_config(cls, runner: helpers.Runners, cfg: Mapping) -> "Apt":
apt_get_upgrade_subcommand=cfg.get("apt_get_upgrade_subcommand"),
)

def available(self) -> bool:
return bool(subp.which(self.apt_get_command[0]))

def update_package_sources(self):
self.runner.run(
"update-sources",
Expand All @@ -123,19 +127,29 @@ def get_all_packages(self):
return set(resp.splitlines())

def get_unavailable_packages(self, pkglist: Iterable[str]):
return [pkg for pkg in pkglist if pkg not in self.get_all_packages()]
# Packages ending with `-` signify to apt to not install a transitive
# dependency.
# Anything after "/" refers to a target release
# "=" allows specifying a specific version
# Strip all off when checking for availability
return [
pkg
for pkg in pkglist
if re.split("/|=|-", pkg)[0] not in self.get_all_packages()
]

def install_packages(self, pkglist: Iterable) -> UninstalledPackages:
self.update_package_sources()
pkglist = util.expand_package_list("%s=%s", list(pkglist))
unavailable = self.get_unavailable_packages(
[x.split("=")[0] for x in pkglist]
)
LOG.debug(
"The following packages were not found by APT so APT will "
"not attempt to install them: %s",
unavailable,
)
if unavailable:
LOG.debug(
"The following packages were not found by APT so APT will "
"not attempt to install them: %s",
unavailable,
)
to_install = [p for p in pkglist if p not in unavailable]
if to_install:
self.run_package_command("install", pkgs=to_install)
Expand Down
4 changes: 4 additions & 0 deletions cloudinit/distros/package_management/package_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ def __init__(self, runner: helpers.Runners, **kwargs):
def from_config(cls, runner: helpers.Runners, cfg) -> "PackageManager":
return cls(runner)

@abstractmethod
def available(self) -> bool:
"""Return if package manager is installed on system."""

@abstractmethod
def update_package_sources(self):
...
Expand Down
3 changes: 3 additions & 0 deletions cloudinit/distros/package_management/snap.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
class Snap(PackageManager):
name = "snap"

def available(self) -> bool:
return bool(subp.which("snap"))

def update_package_sources(self):
pass

Expand Down
19 changes: 19 additions & 0 deletions tests/unittests/distros/package_management/test_apt.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from cloudinit import subp
from cloudinit.distros.package_management.apt import APT_GET_COMMAND, Apt

M_PATH = "cloudinit.distros.package_management.apt.Apt."


@mock.patch.dict("os.environ", {}, clear=True)
@mock.patch("cloudinit.distros.debian.subp.which", return_value=True)
Expand Down Expand Up @@ -86,3 +88,20 @@ def test_lock_exception_timeout(
)
with pytest.raises(TimeoutError):
apt._wait_for_apt_command("stub", {"args": "stub2"}, timeout=5)

def test_search_stem(self, m_subp, m_which, mocker):
"""Test that containing `-`, `/`, or `=` is handled correctly."""
mocker.patch(f"{M_PATH}update_package_sources")
mocker.patch(
f"{M_PATH}get_all_packages",
return_value=["pkg1", "pkg2", "pkg3", "pkg4"],
)
m_install = mocker.patch(f"{M_PATH}run_package_command")

apt = Apt(runner=mock.Mock())
apt.install_packages(
["pkg1", "pkg2-", "pkg3/jammy-updates", "pkg4=1.2"]
)
m_install.assert_called_with(
"install", pkgs=["pkg1", "pkg2-", "pkg3/jammy-updates", "pkg4=1.2"]
)
126 changes: 126 additions & 0 deletions tests/unittests/distros/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,19 @@ def test_valid_substitution(
assert {"primary": expected} == ret


@pytest.fixture
def ensure_available(mocker):
mocker.patch(
"cloudinit.distros.package_management.apt.Apt.available",
return_value=True,
)
mocker.patch(
"cloudinit.distros.package_management.snap.Snap.available",
return_value=True,
)


@pytest.mark.usefixtures("ensure_available")
class TestInstall:
"""Tests for cloudinit.distros.Distro.install_packages."""

Expand Down Expand Up @@ -339,3 +352,116 @@ def test_default_and_specific_package_manager(
assert "pkg3" not in apt_install_args

m_snap_install.assert_not_called()

def test_specific_package_manager_fail_doesnt_retry(
self, mocker, m_snap_install
):
"""Test fail from package manager doesn't retry as generic."""
m_apt_install = mocker.patch(
"cloudinit.distros.package_management.apt.Apt.install_packages",
return_value=["pkg1"],
)
with pytest.raises(PackageInstallerError):
_get_distro("ubuntu").install_packages([{"apt": ["pkg1"]}])
apt_install_args = m_apt_install.call_args_list[0][0][0]
assert "pkg1" in apt_install_args
m_snap_install.assert_not_called()

def test_no_attempt_if_no_package_manager(
self, mocker, m_apt_install, m_snap_install, caplog
):
"""Test that no attempt is made if there are no package manager."""
mocker.patch(
"cloudinit.distros.package_management.apt.Apt.available",
return_value=False,
)
mocker.patch(
"cloudinit.distros.package_management.snap.Snap.available",
return_value=False,
)
with pytest.raises(PackageInstallerError):
_get_distro("ubuntu").install_packages(
["pkg1", "pkg2", {"other": "pkg3"}]
)
m_apt_install.assert_not_called()
m_snap_install.assert_not_called()

assert "Package manager 'apt' not available" in caplog.text
assert "Package manager 'snap' not available" in caplog.text

@pytest.mark.parametrize(
"distro,pkg_list,apt_available,apt_failed,snap_failed,uninstalled",
[
(
"debian",
["pkg1", {"apt": ["pkg2"], "snap": ["pkg3"]}],
False,
[],
["pkg1", "pkg3"],
["pkg1", "pkg2", "pkg3"],
),
(
"debian",
["pkg1", {"apt": ["pkg2"], "snap": ["pkg3"]}],
True,
["pkg2"],
["pkg3"],
["pkg2", "pkg3"],
),
(
"ubuntu",
["pkg1", {"apt": ["pkg2"], "snap": ["pkg3"]}],
False,
[],
[],
["pkg1", "pkg2"],
),
(
"ubuntu",
["pkg1", {"apt": ["pkg2"], "snap": ["pkg3"]}],
True,
["pkg1"],
["pkg3"],
["pkg3"],
),
],
)
def test_uninstalled(
self,
distro,
pkg_list,
apt_available,
apt_failed,
snap_failed,
uninstalled,
mocker,
m_apt_install,
m_snap_install,
):
"""Test that uninstalled packages are properly tracked.
We need to ensure that the uninstalled packages are properly tracked:
1. When package install fails
2. When package manager is not available
3. When package manager is not explicitly supported by the distro
So test various combinations of these scenarios.
"""
mocker.patch(
"cloudinit.distros.package_management.apt.Apt.available",
return_value=apt_available,
)
mocker.patch(
"cloudinit.distros.package_management.apt.Apt.install_packages",
return_value=apt_failed,
)
mocker.patch(
"cloudinit.distros.package_management.snap.Snap.install_packages",
return_value=snap_failed,
)
with pytest.raises(PackageInstallerError) as exc:
_get_distro(distro).install_packages(pkg_list)
message = exc.value.args[0]
assert "Failed to install the following packages" in message
for pkg in uninstalled:
assert pkg in message

0 comments on commit 5ce8d76

Please sign in to comment.