From 4a62c4e3e44b01a904aa86e9b87206a24bd41fbc Mon Sep 17 00:00:00 2001 From: Patrick Hemmer Date: Tue, 11 Jan 2022 09:41:12 -0500 Subject: [PATCH] apt: add support for package version >= (#75002) This adds the ability to specify a package version using >=. This will ensure the package is at the specified version or above. * If the package is not installed, the latest version will be installed. * If the package is installed and less than the specified version, it will be upgraded. * If the package is installed and greater than or equal to the specified version, it will be left alone. The version selection is handled by Apt itself, so things like the system policy, pinning, etc, are considered. --- .../fragments/75002-apt_min_version.yml | 2 + lib/ansible/modules/apt.py | 122 +++++++++--------- test/integration/targets/apt/tasks/repo.yml | 41 ++++++ .../package_specs/{ => stable}/foo-1.0.0 | 0 .../package_specs/{ => stable}/foo-1.0.1 | 0 .../package_specs/{ => stable}/foobar-1.0.0 | 0 .../package_specs/{ => stable}/foobar-1.0.1 | 0 .../files/package_specs/testing/foo-2.0.0 | 10 ++ .../files/package_specs/testing/foo-2.0.1 | 10 ++ .../targets/setup_deb_repo/tasks/main.yml | 53 +++++--- 10 files changed, 162 insertions(+), 76 deletions(-) create mode 100644 changelogs/fragments/75002-apt_min_version.yml rename test/integration/targets/setup_deb_repo/files/package_specs/{ => stable}/foo-1.0.0 (100%) rename test/integration/targets/setup_deb_repo/files/package_specs/{ => stable}/foo-1.0.1 (100%) rename test/integration/targets/setup_deb_repo/files/package_specs/{ => stable}/foobar-1.0.0 (100%) rename test/integration/targets/setup_deb_repo/files/package_specs/{ => stable}/foobar-1.0.1 (100%) create mode 100644 test/integration/targets/setup_deb_repo/files/package_specs/testing/foo-2.0.0 create mode 100644 test/integration/targets/setup_deb_repo/files/package_specs/testing/foo-2.0.1 diff --git a/changelogs/fragments/75002-apt_min_version.yml b/changelogs/fragments/75002-apt_min_version.yml new file mode 100644 index 00000000000000..8d6e8c4fc09618 --- /dev/null +++ b/changelogs/fragments/75002-apt_min_version.yml @@ -0,0 +1,2 @@ +minor_changes: + - apt - Add support for using ">=" in package version number matching. diff --git a/lib/ansible/modules/apt.py b/lib/ansible/modules/apt.py index 7e4ac75ff0d5a7..49381537753958 100644 --- a/lib/ansible/modules/apt.py +++ b/lib/ansible/modules/apt.py @@ -20,7 +20,7 @@ options: name: description: - - A list of package names, like C(foo), or package specifier with version, like C(foo=1.0). + - A list of package names, like C(foo), or package specifier with version, like C(foo=1.0) or C(foo>=1.0). Name wildcards (fnmatch) like C(apt*) and version wildcards like C(foo=1.0*) are also supported. aliases: [ package, pkg ] type: list @@ -454,24 +454,10 @@ def __exit__(self, type, value, traceback): def package_split(pkgspec): - parts = pkgspec.split('=', 1) - version = None + parts = re.split(r'(>?=)', pkgspec, 1) if len(parts) > 1: - version = parts[1] - return parts[0], version - - -def package_versions(pkgname, pkg, pkg_cache): - try: - versions = set(p.version for p in pkg.versions) - except AttributeError: - # assume older version of python-apt is installed - # apt.package.Package#versions require python-apt >= 0.7.9. - pkg_cache_list = (p for p in pkg_cache.Packages if p.Name == pkgname) - pkg_versions = (p.VersionList for p in pkg_cache_list) - versions = set(p.VerStr for p in itertools.chain(*pkg_versions)) - - return versions + return parts + return parts[0], None, None def package_version_compare(version, other_version): @@ -481,7 +467,26 @@ def package_version_compare(version, other_version): return apt_pkg.VersionCompare(version, other_version) -def package_status(m, pkgname, version, cache, state): +def package_best_match(pkgname, version_cmp, version, release, cache): + policy = apt_pkg.Policy(cache) + if release: + # 990 is the priority used in `apt-get -t` + policy.create_pin('Release', pkgname, release, 990) + if version_cmp == "=": + # You can't pin to a minimum version, only equality with a glob + policy.create_pin('Version', pkgname, version, 991) + pkg = cache[pkgname] + pkgver = policy.get_candidate_ver(pkg) + if not pkgver: + return None + if version_cmp == "=" and not fnmatch.fnmatch(pkgver.ver_str, version): + # Even though we put in a pin policy, it can be ignored if there is no + # possible candidate. + return None + return pkgver.ver_str + + +def package_status(m, pkgname, version_cmp, version, default_release, cache, state): try: # get the package from the cache, as well as the # low-level apt_pkg.Package object which contains @@ -495,20 +500,21 @@ def package_status(m, pkgname, version, cache, state): provided_packages = cache.get_providing_packages(pkgname) if provided_packages: is_installed = False - upgradable = False + version_installable = None version_ok = False # when virtual package providing only one package, look up status of target package if cache.is_virtual_package(pkgname) and len(provided_packages) == 1: package = provided_packages[0] - installed, version_ok, upgradable, has_files = package_status(m, package.name, version, cache, state='install') + installed, version_ok, version_installable, has_files = \ + package_status(m, package.name, version_cmp, version, default_release, cache, state='install') if installed: is_installed = True - return is_installed, version_ok, upgradable, False + return is_installed, version_ok, version_installable, False m.fail_json(msg="No package matching '%s' is available" % pkgname) except AttributeError: # python-apt version too old to detect virtual packages - # mark as upgradable and let apt-get install deal with it - return False, False, True, False + # mark as not installed and let apt-get install deal with it + return False, False, None, False else: return False, False, False, False try: @@ -528,36 +534,29 @@ def package_status(m, pkgname, version, cache, state): # assume older version of python-apt is installed package_is_installed = pkg.isInstalled - version_is_installed = package_is_installed - if version: - versions = package_versions(pkgname, pkg, cache._cache) - avail_upgrades = fnmatch.filter(versions, version) - - if package_is_installed: - try: - installed_version = pkg.installed.version - except AttributeError: - installed_version = pkg.installedVersion + version_best = package_best_match(pkgname, version_cmp, version, default_release, cache._cache) + version_is_installed = False + version_installable = None + if package_is_installed: + try: + installed_version = pkg.installed.version + except AttributeError: + installed_version = pkg.installedVersion + if version_cmp == "=": # check if the version is matched as well version_is_installed = fnmatch.fnmatch(installed_version, version) - - # Only claim the package is upgradable if a candidate matches the version - package_is_upgradable = False - for candidate in avail_upgrades: - if package_version_compare(candidate, installed_version) > 0: - package_is_upgradable = True - break + elif version_cmp == ">=": + version_is_installed = apt_pkg.version_compare(installed_version, version) >= 0 else: - package_is_upgradable = bool(avail_upgrades) + version_is_installed = True + + if installed_version != version_best: + version_installable = version_best else: - try: - package_is_upgradable = pkg.is_upgradable - except AttributeError: - # assume older version of python-apt is installed - package_is_upgradable = pkg.isUpgradable + version_installable = version_best - return package_is_installed, version_is_installed, package_is_upgradable, has_files + return package_is_installed, version_is_installed, version_installable, has_files def expand_dpkg_options(dpkg_options_compressed): @@ -581,7 +580,7 @@ def expand_pkgspec_from_fnmatches(m, pkgspec, cache): new_pkgspec = [] if pkgspec: for pkgspec_pattern in pkgspec: - pkgname_pattern, version = package_split(pkgspec_pattern) + pkgname_pattern, version_cmp, version = package_split(pkgspec_pattern) # note that none of these chars is allowed in a (debian) pkgname if frozenset('*?[]!').intersection(pkgname_pattern): @@ -671,19 +670,26 @@ def install(m, pkgspec, cache, upgrade=False, default_release=None, pkg_list.append("'%s'" % package) continue - name, version = package_split(package) + name, version_cmp, version = package_split(package) package_names.append(name) - installed, installed_version, upgradable, has_files = package_status(m, name, version, cache, state='install') - if (not installed and not only_upgrade) or (installed and not installed_version) or (upgrade and upgradable): - pkg_list.append("'%s'" % package) - if installed_version and upgradable and version: + installed, installed_version, version_installable, has_files = package_status(m, name, version_cmp, version, default_release, cache, state='install') + if (not installed and not only_upgrade) or (installed and not installed_version) or (upgrade and version_installable): + if version_installable or version: + pkg_list.append("'%s=%s'" % (name, version_installable or version)) + else: + pkg_list.append("'%s'" % name) + elif installed_version and version_installable and version_cmp == "=": # This happens when the package is installed, a newer version is # available, and the version is a wildcard that matches both # # We do not apply the upgrade flag because we cannot specify both # a version and state=latest. (This behaviour mirrors how apt # treats a version with wildcard in the package) - pkg_list.append("'%s'" % package) + # + # This is legacy behavior, and isn't documented (in fact it does + # things documentations says it shouldn't). It should not be relied + # upon. + pkg_list.append("'%s=%s'" % (name, version_installable)) packages = ' '.join(pkg_list) if packages: @@ -877,8 +883,8 @@ def remove(m, pkgspec, cache, purge=False, force=False, pkg_list = [] pkgspec = expand_pkgspec_from_fnmatches(m, pkgspec, cache) for package in pkgspec: - name, version = package_split(package) - installed, installed_version, upgradable, has_files = package_status(m, name, version, cache, state='remove') + name, version_cmp, version = package_split(package) + installed, installed_version, upgradable, has_files = package_status(m, name, version_cmp, version, None, cache, state='remove') if installed_version or (has_files and purge): pkg_list.append("'%s'" % package) packages = ' '.join(pkg_list) @@ -1340,8 +1346,6 @@ def main(): for package in packages: if package.count('=') > 1: module.fail_json(msg="invalid package spec: %s" % package) - if latest and '=' in package: - module.fail_json(msg='version number inconsistent with state=latest: %s' % package) if not packages: if autoclean: diff --git a/test/integration/targets/apt/tasks/repo.yml b/test/integration/targets/apt/tasks/repo.yml index 8a0b92b3834b90..1705cb3e73cb7e 100644 --- a/test/integration/targets/apt/tasks/repo.yml +++ b/test/integration/targets/apt/tasks/repo.yml @@ -86,6 +86,47 @@ state: absent allow_unauthenticated: yes +- block: + - name: Install foo=1.0.0 + apt: + name: foo=1.0.0 + + - name: Run version test matrix + apt: + name: foo{{ item.0 }} + default_release: '{{ item.1 }}' + state: '{{ item.2 | ternary("latest","present") }}' + check_mode: true + register: apt_result + loop: + # [filter, release, state_latest, expected] + - ["", null, false, null] + - ["", null, true, "1.0.1"] + - ["=1.0.0", null, false, null] + - ["=1.0.0", null, true, null] + - ["=1.0.1", null, false, "1.0.1"] + #- ["=1.0.*", null, false, null] # legacy behavior. should not upgrade without state=latest + - ["=1.0.*", null, true, "1.0.1"] + - [">=1.0.0", null, false, null] + - [">=1.0.0", null, true, "1.0.1"] + - [">=1.0.1", null, false, "1.0.1"] + - ["", "testing", false, null] + - ["", "testing", true, "2.0.1"] + - ["=2.0.0", null, false, "2.0.0"] + - [">=2.0.0", "testing", false, "2.0.1"] + + - name: Validate version test matrix + assert: + that: + - (item.item.3 is not none) == (item.stdout is defined) + - item.item.3 is none or "Inst foo [1.0.0] (" + item.item.3 + " localhost [all])" in item.stdout_lines + loop: '{{ apt_result.results }}' + + always: + - name: Uninstall foo + apt: + name: foo + state: absent # https://github.com/ansible/ansible/issues/35900 - block: diff --git a/test/integration/targets/setup_deb_repo/files/package_specs/foo-1.0.0 b/test/integration/targets/setup_deb_repo/files/package_specs/stable/foo-1.0.0 similarity index 100% rename from test/integration/targets/setup_deb_repo/files/package_specs/foo-1.0.0 rename to test/integration/targets/setup_deb_repo/files/package_specs/stable/foo-1.0.0 diff --git a/test/integration/targets/setup_deb_repo/files/package_specs/foo-1.0.1 b/test/integration/targets/setup_deb_repo/files/package_specs/stable/foo-1.0.1 similarity index 100% rename from test/integration/targets/setup_deb_repo/files/package_specs/foo-1.0.1 rename to test/integration/targets/setup_deb_repo/files/package_specs/stable/foo-1.0.1 diff --git a/test/integration/targets/setup_deb_repo/files/package_specs/foobar-1.0.0 b/test/integration/targets/setup_deb_repo/files/package_specs/stable/foobar-1.0.0 similarity index 100% rename from test/integration/targets/setup_deb_repo/files/package_specs/foobar-1.0.0 rename to test/integration/targets/setup_deb_repo/files/package_specs/stable/foobar-1.0.0 diff --git a/test/integration/targets/setup_deb_repo/files/package_specs/foobar-1.0.1 b/test/integration/targets/setup_deb_repo/files/package_specs/stable/foobar-1.0.1 similarity index 100% rename from test/integration/targets/setup_deb_repo/files/package_specs/foobar-1.0.1 rename to test/integration/targets/setup_deb_repo/files/package_specs/stable/foobar-1.0.1 diff --git a/test/integration/targets/setup_deb_repo/files/package_specs/testing/foo-2.0.0 b/test/integration/targets/setup_deb_repo/files/package_specs/testing/foo-2.0.0 new file mode 100644 index 00000000000000..7e835f05c402b1 --- /dev/null +++ b/test/integration/targets/setup_deb_repo/files/package_specs/testing/foo-2.0.0 @@ -0,0 +1,10 @@ +Section: misc +Priority: optional +Standards-Version: 2.3.3 + +Package: foo +Version: 2.0.0 +Section: system +Maintainer: John Doe +Architecture: all +Description: Dummy package diff --git a/test/integration/targets/setup_deb_repo/files/package_specs/testing/foo-2.0.1 b/test/integration/targets/setup_deb_repo/files/package_specs/testing/foo-2.0.1 new file mode 100644 index 00000000000000..c6e7b5ba2f9b0b --- /dev/null +++ b/test/integration/targets/setup_deb_repo/files/package_specs/testing/foo-2.0.1 @@ -0,0 +1,10 @@ +Section: misc +Priority: optional +Standards-Version: 2.3.3 + +Package: foo +Version: 2.0.1 +Section: system +Maintainer: John Doe +Architecture: all +Description: Dummy package diff --git a/test/integration/targets/setup_deb_repo/tasks/main.yml b/test/integration/targets/setup_deb_repo/tasks/main.yml index 49f68a2cdfb699..471fb2a2c1a3e8 100644 --- a/test/integration/targets/setup_deb_repo/tasks/main.yml +++ b/test/integration/targets/setup_deb_repo/tasks/main.yml @@ -10,36 +10,55 @@ - set_fact: repodir: /tmp/repo/ - - name: Create repo dir + - name: Create repo dirs file: - path: "{{ repodir }}" + path: "{{ repodir }}/dists/{{ item }}/main/binary-all" state: directory mode: 0755 + loop: + - stable + - testing - name: Copy package specs to remote copy: - src: "{{ item }}" - dest: "{{ remote_tmp_dir }}/{{ item | basename }}" - with_fileglob: - - "files/package_specs/*" + src: package_specs + dest: "{{ remote_tmp_dir }}" - name: Create deb files - shell: "equivs-build {{ remote_tmp_dir }}/{{ item | basename }}" + shell: "find {{ remote_tmp_dir }}/package_specs/{{ item }} -type f -exec equivs-build {} \\;" args: - chdir: "{{ repodir }}" - with_fileglob: - - "files/package_specs/*" + chdir: "{{ repodir }}/dists/{{ item }}/main/binary-all" + loop: + - stable + - testing - - name: Create repo - shell: dpkg-scanpackages --multiversion . /dev/null | gzip -9c > Packages.gz + - name: Create repo Packages + shell: dpkg-scanpackages --multiversion . /dev/null dists/{{ item }}/main/binary-all/ | gzip -9c > Packages.gz args: - chdir: "{{ repodir }}" + chdir: "{{ repodir }}/dists/{{ item }}/main/binary-all" + loop: + - stable + - testing - # Can't use apt_repository as it doesn't expose a trusted=yes option - - name: Install the repo + - name: Create repo Release copy: - content: deb [trusted=yes] file:{{ repodir }} ./ - dest: /etc/apt/sources.list.d/file_tmp_repo.list + content: | + Codename: {{ item.0 }} + {% for k,v in item.1.items() %} + {{ k }}: {{ v }} + {% endfor %} + dest: "{{ repodir }}/dists/{{ item.0 }}/Release" + loop: + - [stable, {}] + - [testing, {NotAutomatic: "yes", ButAutomaticUpgrades: "yes"}] + + - name: Install the repo + apt_repository: + repo: deb [trusted=yes arch=all] file:{{ repodir }} {{ item }} main + update_cache: false # interferes with task 'Test update_cache 1' + loop: + - stable + - testing # Need to uncomment the deb-src for the universe component for build-dep state - name: Ensure deb-src for the universe component