diff --git a/changelogs/fragments/75002-apt_min_version.yml b/changelogs/fragments/75002-apt_min_version.yml new file mode 100644 index 00000000000..8d6e8c4fc09 --- /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 7e4ac75ff0d..49381537753 100644 --- a/lib/ansible/modules/apt.py +++ b/lib/ansible/modules/apt.py @@ -20,7 +20,7 @@ version_added: "0.0.2" 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 @@ class PolicyRcD(object): 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 8a0b92b3834..1705cb3e73c 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 00000000000..7e835f05c40 --- /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 00000000000..c6e7b5ba2f9 --- /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 49f68a2cdfb..471fb2a2c1a 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