diff --git a/changelogs/fragments/3526-pkgng-add-integration-tests.yml b/changelogs/fragments/3526-pkgng-add-integration-tests.yml new file mode 100644 index 0000000000..a676f50476 --- /dev/null +++ b/changelogs/fragments/3526-pkgng-add-integration-tests.yml @@ -0,0 +1,6 @@ +bugfixes: + - 'pkgng - ``name=* state=latest`` check for upgrades did not count "Number of packages to be reinstalled" as a `changed` action, giving incorrect results in both regular and check mode (https://github.com/ansible-collections/community.general/pull/3526).' + - 'pkgng - an `earlier PR `_ broke check mode so that the module always reports `not changed`. This is now fixed so that the module reports number of upgrade or install actions that would be performed (https://github.com/ansible-collections/community.general/pull/3526).' + - 'pkgng - the ``annotation`` functionality was broken and is now fixed, and now also works with check mode (https://github.com/ansible-collections/community.general/pull/3526).' +minor_changes: + - 'pkgng - ``annotation`` can now also be a YAML list (https://github.com/ansible-collections/community.general/pull/3526).' diff --git a/plugins/modules/packaging/os/pkgng.py b/plugins/modules/packaging/os/pkgng.py index 4b033dd738..ff7e45fa96 100644 --- a/plugins/modules/packaging/os/pkgng.py +++ b/plugins/modules/packaging/os/pkgng.py @@ -50,13 +50,14 @@ options: default: no annotation: description: - - A comma-separated list of keyvalue-pairs of the form + - A list of keyvalue-pairs of the form C(<+/-/:>[=]). A C(+) denotes adding an annotation, a C(-) denotes removing an annotation, and C(:) denotes modifying an annotation. If setting or modifying annotations, a value must be provided. required: false - type: str + type: list + elements: str pkgsite: description: - For pkgng versions before 1.1.4, specify packagesite to use @@ -113,12 +114,16 @@ EXAMPLES = ''' - name: Annotate package foo and bar community.general.pkgng: - name: foo,bar + name: + - foo + - bar annotation: '+test1=baz,-test2,:test3=foobar' - name: Remove packages foo and bar community.general.pkgng: - name: foo,bar + name: + - foo + - bar state: absent # "latest" support added in 2.7 @@ -139,9 +144,9 @@ import re from ansible.module_utils.basic import AnsibleModule -def query_package(module, pkgng_path, name, dir_arg): +def query_package(module, run_pkgng, name): - rc, out, err = module.run_command("%s %s info -g -e %s" % (pkgng_path, dir_arg, name)) + rc, out, err = run_pkgng('info', '-g', '-e', name) if rc == 0: return True @@ -149,15 +154,12 @@ def query_package(module, pkgng_path, name, dir_arg): return False -def query_update(module, pkgng_path, name, dir_arg, old_pkgng, pkgsite): +def query_update(module, run_pkgng, name): # Check to see if a package upgrade is available. # rc = 0, no updates available or package not installed # rc = 1, updates available - if old_pkgng: - rc, out, err = module.run_command("%s %s upgrade -g -n %s" % (pkgsite, pkgng_path, name)) - else: - rc, out, err = module.run_command("%s %s upgrade %s -g -n %s" % (pkgng_path, dir_arg, pkgsite, name)) + rc, out, err = run_pkgng('upgrade', '-g', '-n', name) if rc == 1: return True @@ -167,7 +169,7 @@ def query_update(module, pkgng_path, name, dir_arg, old_pkgng, pkgsite): def pkgng_older_than(module, pkgng_path, compare_version): - rc, out, err = module.run_command("%s -v" % pkgng_path) + rc, out, err = module.run_command([pkgng_path, '-v']) version = [int(x) for x in re.split(r'[\._]', out)] i = 0 @@ -182,40 +184,39 @@ def pkgng_older_than(module, pkgng_path, compare_version): return not new_pkgng -def upgrade_packages(module, pkgng_path, dir_arg): +def upgrade_packages(module, run_pkgng): # Run a 'pkg upgrade', updating all packages. upgraded_c = 0 - cmd = "%s %s upgrade -y" % (pkgng_path, dir_arg) - if module.check_mode: - cmd += " -n" - rc, out, err = module.run_command(cmd) + pkgng_args = ['upgrade'] + pkgng_args.append('-n' if module.check_mode else '-y') + rc, out, err = run_pkgng(*pkgng_args) - match = re.search('^Number of packages to be upgraded: ([0-9]+)', out, re.MULTILINE) - if match: - upgraded_c = int(match.group(1)) + matches = re.findall('^Number of packages to be (?:upgraded|reinstalled): ([0-9]+)', out, re.MULTILINE) + for match in matches: + upgraded_c += int(match) if upgraded_c > 0: return (True, "updated %s package(s)" % upgraded_c, out, err) return (False, "no packages need upgrades", out, err) -def remove_packages(module, pkgng_path, packages, dir_arg): +def remove_packages(module, run_pkgng, packages): remove_c = 0 stdout = "" stderr = "" # Using a for loop in case of error, we can report the package that failed for package in packages: # Query the package first, to see if we even need to remove - if not query_package(module, pkgng_path, package, dir_arg): + if not query_package(module, run_pkgng, package): continue if not module.check_mode: - rc, out, err = module.run_command("%s %s delete -y %s" % (pkgng_path, dir_arg, package)) + rc, out, err = run_pkgng('delete', '-y', package) stdout += out stderr += err - if not module.check_mode and query_package(module, pkgng_path, package, dir_arg): + if not module.check_mode and query_package(module, run_pkgng, package): module.fail_json(msg="failed to remove %s: %s" % (package, out), stdout=stdout, stderr=stderr) remove_c += 1 @@ -226,48 +227,27 @@ def remove_packages(module, pkgng_path, packages, dir_arg): return (False, "package(s) already absent", stdout, stderr) -def install_packages(module, pkgng_path, packages, cached, pkgsite, dir_arg, state, ignoreosver): +def install_packages(module, run_pkgng, packages, cached, state): action_queue = defaultdict(list) action_count = defaultdict(int) stdout = "" stderr = "" - # as of pkg-1.1.4, PACKAGESITE is deprecated in favor of repository definitions - # in /usr/local/etc/pkg/repos - old_pkgng = pkgng_older_than(module, pkgng_path, [1, 1, 4]) - if pkgsite != "": - if old_pkgng: - pkgsite = "PACKAGESITE=%s" % (pkgsite) - else: - pkgsite = "-r %s" % (pkgsite) - - # This environment variable skips mid-install prompts, - # setting them to their default values. - batch_var = 'env BATCH=yes' - - if ignoreosver: - # Ignore FreeBSD OS version check, - # useful on -STABLE and -CURRENT branches. - batch_var = batch_var + ' IGNORE_OSVERSION=yes' - if not module.check_mode and not cached: - if old_pkgng: - rc, out, err = module.run_command("%s %s update" % (pkgsite, pkgng_path)) - else: - rc, out, err = module.run_command("%s %s %s update" % (batch_var, pkgng_path, dir_arg)) + rc, out, err = run_pkgng('update') stdout += out stderr += err if rc != 0: module.fail_json(msg="Could not update catalogue [%d]: %s %s" % (rc, out, err), stdout=stdout, stderr=stderr) for package in packages: - already_installed = query_package(module, pkgng_path, package, dir_arg) + already_installed = query_package(module, run_pkgng, package) if already_installed and state == "present": continue if ( already_installed and state == "latest" - and not query_update(module, pkgng_path, package, dir_arg, old_pkgng, pkgsite) + and not query_update(module, run_pkgng, package) ): continue @@ -276,29 +256,32 @@ def install_packages(module, pkgng_path, packages, cached, pkgsite, dir_arg, sta else: action_queue["install"].append(package) - if not module.check_mode: - # install/upgrade all named packages with one pkg command - for (action, package_list) in action_queue.items(): - packages = ' '.join(package_list) - if old_pkgng: - rc, out, err = module.run_command("%s %s %s %s -g -U -y %s" % (batch_var, pkgsite, pkgng_path, action, packages)) + # install/upgrade all named packages with one pkg command + for (action, package_list) in action_queue.items(): + if module.check_mode: + # Do nothing, but count up how many actions + # would be performed so that the changed/msg + # is correct. + action_count[action] += len(package_list) + continue + + pkgng_args = [action, '-g', '-U', '-y'] + package_list + rc, out, err = run_pkgng(*pkgng_args) + stdout += out + stderr += err + + # individually verify packages are in requested state + for package in package_list: + verified = False + if action == 'install': + verified = query_package(module, run_pkgng, package) + elif action == 'upgrade': + verified = not query_update(module, run_pkgng, package) + + if verified: + action_count[action] += 1 else: - rc, out, err = module.run_command("%s %s %s %s %s -g -U -y %s" % (batch_var, pkgng_path, dir_arg, action, pkgsite, packages)) - stdout += out - stderr += err - - # individually verify packages are in requested state - for package in package_list: - verified = False - if action == 'install': - verified = query_package(module, pkgng_path, package, dir_arg) - elif action == 'upgrade': - verified = not query_update(module, pkgng_path, package, dir_arg, old_pkgng, pkgsite) - - if verified: - action_count[action] += 1 - else: - module.fail_json(msg="failed to %s %s" % (action, package), stdout=stdout, stderr=stderr) + module.fail_json(msg="failed to %s %s" % (action, package), stdout=stdout, stderr=stderr) if sum(action_count.values()) > 0: past_tense = {'install': 'installed', 'upgrade': 'upgraded'} @@ -311,28 +294,28 @@ def install_packages(module, pkgng_path, packages, cached, pkgsite, dir_arg, sta return (False, "package(s) already %s" % (state), stdout, stderr) -def annotation_query(module, pkgng_path, package, tag, dir_arg): - rc, out, err = module.run_command("%s %s info -g -A %s" % (pkgng_path, dir_arg, package)) +def annotation_query(module, run_pkgng, package, tag): + rc, out, err = run_pkgng('info', '-g', '-A', package) match = re.search(r'^\s*(?P%s)\s*:\s*(?P\w+)' % tag, out, flags=re.MULTILINE) if match: return match.group('value') return False -def annotation_add(module, pkgng_path, package, tag, value, dir_arg): - _value = annotation_query(module, pkgng_path, package, tag, dir_arg) +def annotation_add(module, run_pkgng, package, tag, value): + _value = annotation_query(module, run_pkgng, package, tag) if not _value: # Annotation does not exist, add it. - rc, out, err = module.run_command('%s %s annotate -y -A %s %s "%s"' - % (pkgng_path, dir_arg, package, tag, value)) - if rc != 0: - module.fail_json(msg="could not annotate %s: %s" - % (package, out), stderr=err) + if not module.check_mode: + rc, out, err = run_pkgng('annotate', '-y', '-A', package, tag, data=value, binary_data=True) + if rc != 0: + module.fail_json(msg="could not annotate %s: %s" + % (package, out), stderr=err) return True elif _value != value: # Annotation exists, but value differs module.fail_json( - mgs="failed to annotate %s, because %s is already set to %s, but should be set to %s" + msg="failed to annotate %s, because %s is already set to %s, but should be set to %s" % (package, tag, _value, value)) return False else: @@ -340,21 +323,21 @@ def annotation_add(module, pkgng_path, package, tag, value, dir_arg): return False -def annotation_delete(module, pkgng_path, package, tag, value, dir_arg): - _value = annotation_query(module, pkgng_path, package, tag, dir_arg) +def annotation_delete(module, run_pkgng, package, tag, value): + _value = annotation_query(module, run_pkgng, package, tag) if _value: - rc, out, err = module.run_command('%s %s annotate -y -D %s %s' - % (pkgng_path, dir_arg, package, tag)) - if rc != 0: - module.fail_json(msg="could not delete annotation to %s: %s" - % (package, out), stderr=err) + if not module.check_mode: + rc, out, err = run_pkgng('annotate', '-y', '-D', package, tag) + if rc != 0: + module.fail_json(msg="could not delete annotation to %s: %s" + % (package, out), stderr=err) return True return False -def annotation_modify(module, pkgng_path, package, tag, value, dir_arg): - _value = annotation_query(module, pkgng_path, package, tag, dir_arg) - if not value: +def annotation_modify(module, run_pkgng, package, tag, value): + _value = annotation_query(module, run_pkgng, package, tag) + if not _value: # No such tag module.fail_json(msg="could not change annotation to %s: tag %s does not exist" % (package, tag)) @@ -362,20 +345,27 @@ def annotation_modify(module, pkgng_path, package, tag, value, dir_arg): # No change in value return False else: - rc, out, err = module.run_command('%s %s annotate -y -M %s %s "%s"' - % (pkgng_path, dir_arg, package, tag, value)) - if rc != 0: - module.fail_json(msg="could not change annotation annotation to %s: %s" - % (package, out), stderr=err) + if not module.check_mode: + rc, out, err = run_pkgng('annotate', '-y', '-M', package, tag, data=value, binary_data=True) + + # pkg sometimes exits with rc == 1, even though the modification succeeded + # Check the output for a success message + if ( + rc != 0 + and re.search(r'^%s-[^:]+: Modified annotation tagged: %s' % (package, tag), out, flags=re.MULTILINE) is None + ): + module.fail_json(msg="failed to annotate %s, could not change annotation %s to %s: %s" + % (package, tag, value, out), stderr=err) return True -def annotate_packages(module, pkgng_path, packages, annotation, dir_arg): +def annotate_packages(module, run_pkgng, packages, annotations): annotate_c = 0 - annotations = map(lambda _annotation: - re.match(r'(?P[\+-:])(?P\w+)(=(?P\w+))?', - _annotation).groupdict(), - re.split(r',', annotation)) + if len(annotations) == 1: + # Split on commas with optional trailing whitespace, + # to support the old style of multiple annotations + # on a single line, rather than YAML list syntax + annotations = re.split(r'\s*,\s*', annotations[0]) operation = { '+': annotation_add, @@ -384,8 +374,21 @@ def annotate_packages(module, pkgng_path, packages, annotation, dir_arg): } for package in packages: - for _annotation in annotations: - if operation[_annotation['operation']](module, pkgng_path, package, _annotation['tag'], _annotation['value']): + for annotation_string in annotations: + # Note to future maintainers: A dash (-) in a regex character class ([-+:] below) + # must appear as the first character in the class, or it will be interpreted + # as a range of characters. + annotation = \ + re.match(r'(?P[-+:])(?P[^=]+)(=(?P.+))?', annotation_string) + + if annotation is None: + module.fail_json( + msg="failed to annotate %s, invalid annotate string: %s" + % (package, annotation_string) + ) + + annotation = annotation.groupdict() + if operation[annotation['operation']](module, run_pkgng, package, annotation['tag'], annotation['value']): annotate_c += 1 if annotate_c > 0: @@ -393,10 +396,10 @@ def annotate_packages(module, pkgng_path, packages, annotation, dir_arg): return (False, "changed no annotations") -def autoremove_packages(module, pkgng_path, dir_arg): +def autoremove_packages(module, run_pkgng): stdout = "" stderr = "" - rc, out, err = module.run_command("%s %s autoremove -n" % (pkgng_path, dir_arg)) + rc, out, err = run_pkgng('autoremove', '-n') autoremove_c = 0 @@ -408,7 +411,7 @@ def autoremove_packages(module, pkgng_path, dir_arg): return (False, "no package(s) to autoremove", stdout, stderr) if not module.check_mode: - rc, out, err = module.run_command("%s %s autoremove -y" % (pkgng_path, dir_arg)) + rc, out, err = run_pkgng('autoremove', '-y') stdout += out stderr += err @@ -422,11 +425,11 @@ def main(): name=dict(aliases=["pkg"], required=True, type='list', elements='str'), cached=dict(default=False, type='bool'), ignore_osver=dict(default=False, required=False, type='bool'), - annotation=dict(default="", required=False), - pkgsite=dict(default="", required=False), - rootdir=dict(default="", required=False, type='path'), - chroot=dict(default="", required=False, type='path'), - jail=dict(default="", required=False, type='str'), + annotation=dict(required=False, type='list', elements='str'), + pkgsite=dict(required=False), + rootdir=dict(required=False, type='path'), + chroot=dict(required=False, type='path'), + jail=dict(required=False, type='str'), autoremove=dict(default=False, type='bool')), supports_check_mode=True, mutually_exclusive=[["rootdir", "chroot", "jail"]]) @@ -441,61 +444,90 @@ def main(): msgs = [] stdout = "" stderr = "" - dir_arg = "" + dir_arg = None - if p["rootdir"] != "": - old_pkgng = pkgng_older_than(module, pkgng_path, [1, 5, 0]) - if old_pkgng: + if p["rootdir"] is not None: + rootdir_not_supported = pkgng_older_than(module, pkgng_path, [1, 5, 0]) + if rootdir_not_supported: module.fail_json(msg="To use option 'rootdir' pkg version must be 1.5 or greater") else: - dir_arg = "--rootdir %s" % (p["rootdir"]) + dir_arg = "--rootdir=%s" % (p["rootdir"]) if p["ignore_osver"]: - old_pkgng = pkgng_older_than(module, pkgng_path, [1, 11, 0]) - if old_pkgng: + ignore_osver_not_supported = pkgng_older_than(module, pkgng_path, [1, 11, 0]) + if ignore_osver_not_supported: module.fail_json(msg="To use option 'ignore_osver' pkg version must be 1.11 or greater") - if p["chroot"] != "": - dir_arg = '--chroot %s' % (p["chroot"]) + if p["chroot"] is not None: + dir_arg = '--chroot=%s' % (p["chroot"]) - if p["jail"] != "": - dir_arg = '--jail %s' % (p["jail"]) + if p["jail"] is not None: + dir_arg = '--jail=%s' % (p["jail"]) + + # as of pkg-1.1.4, PACKAGESITE is deprecated in favor of repository definitions + # in /usr/local/etc/pkg/repos + repo_flag_not_supported = pkgng_older_than(module, pkgng_path, [1, 1, 4]) + + def run_pkgng(action, *args, **kwargs): + cmd = [pkgng_path, dir_arg, action] + + pkgng_env = {'BATCH': 'yes'} + + if p["ignore_osver"]: + pkgng_env['IGNORE_OSVERSION'] = 'yes' + + if p['pkgsite'] is not None and action in ('update', 'install', 'upgrade',): + if repo_flag_not_supported: + pkgng_env['PACKAGESITE'] = p['pkgsite'] + else: + cmd.append('--repository=%s' % (p['pkgsite'],)) + + # If environ_update is specified to be "passed through" + # to module.run_command, then merge its values into pkgng_env + pkgng_env.update(kwargs.pop('environ_update', dict())) + + return module.run_command(cmd + list(args), environ_update=pkgng_env, **kwargs) if pkgs == ['*'] and p["state"] == 'latest': # Operate on all installed packages. Only state: latest makes sense here. - _changed, _msg, _stdout, _stderr = upgrade_packages(module, pkgng_path, dir_arg) + _changed, _msg, _stdout, _stderr = upgrade_packages(module, run_pkgng) changed = changed or _changed stdout += _stdout stderr += _stderr msgs.append(_msg) # Operate on named packages + if len(pkgs) == 1: + # The documentation used to show multiple packages specified in one line + # with comma or space delimiters. That doesn't result in a YAML list, and + # wrong actions (install vs upgrade) can be reported if those + # comma- or space-delimited strings make it to the pkg command line. + pkgs = re.split(r'[,\s]', pkgs[0]) named_packages = [pkg for pkg in pkgs if pkg != '*'] if p["state"] in ("present", "latest") and named_packages: - _changed, _msg, _out, _err = install_packages(module, pkgng_path, named_packages, - p["cached"], p["pkgsite"], dir_arg, - p["state"], p["ignore_osver"]) + _changed, _msg, _out, _err = install_packages(module, run_pkgng, named_packages, + p["cached"], p["state"]) stdout += _out stderr += _err changed = changed or _changed msgs.append(_msg) elif p["state"] == "absent" and named_packages: - _changed, _msg, _out, _err = remove_packages(module, pkgng_path, named_packages, dir_arg) + _changed, _msg, _out, _err = remove_packages(module, run_pkgng, named_packages) stdout += _out stderr += _err changed = changed or _changed msgs.append(_msg) if p["autoremove"]: - _changed, _msg, _stdout, _stderr = autoremove_packages(module, pkgng_path, dir_arg) + _changed, _msg, _stdout, _stderr = autoremove_packages(module, run_pkgng) changed = changed or _changed stdout += _stdout stderr += _stderr msgs.append(_msg) - if p["annotation"]: - _changed, _msg = annotate_packages(module, pkgng_path, pkgs, p["annotation"], dir_arg) + if p["annotation"] is not None: + _changed, _msg = annotate_packages(module, run_pkgng, pkgs, p["annotation"]) changed = changed or _changed msgs.append(_msg) diff --git a/tests/integration/targets/pkgng/aliases b/tests/integration/targets/pkgng/aliases new file mode 100644 index 0000000000..360849e61b --- /dev/null +++ b/tests/integration/targets/pkgng/aliases @@ -0,0 +1,5 @@ +shippable/posix/group1 +needs/root +skip/docker +skip/osx +skip/rhel diff --git a/tests/integration/targets/pkgng/tasks/create-outofdate-pkg.yml b/tests/integration/targets/pkgng/tasks/create-outofdate-pkg.yml new file mode 100644 index 0000000000..94635db697 --- /dev/null +++ b/tests/integration/targets/pkgng/tasks/create-outofdate-pkg.yml @@ -0,0 +1,49 @@ +--- +- name: Create temporary directory for package creation + tempfile: + state: directory + register: pkgng_test_outofdate_pkg_tempdir + +- name: Copy intentionally out-of-date package manifest to testhost + template: + src: MANIFEST.json.j2 + # Plus-sign must be added at the destination + # CI doesn't like files with '+' in them in the repository + dest: '{{ pkgng_test_outofdate_pkg_tempdir.path }}/MANIFEST' + +- name: Create out-of-date test package file + command: + argv: + - pkg + - create + - '--verbose' + - '--out-dir' + - '{{ pkgng_test_outofdate_pkg_tempdir.path }}' + - '--manifest' + - '{{ pkgng_test_outofdate_pkg_tempdir.path }}/MANIFEST' + warn: no + +# pkg switched from .txz to .pkg in version 1.17.0 +# Might as well look for all valid pkg extensions. +- name: Find created package file + find: + path: '{{ pkgng_test_outofdate_pkg_tempdir.path }}' + use_regex: yes + pattern: '.*\.(pkg|tzst|t[xbg]z|tar)' + register: pkgng_test_outofdate_pkg_tempfile + +- name: There should be only one package + assert: + that: + - pkgng_test_outofdate_pkg_tempfile.files | count == 1 + +- name: Copy the created package file to the expected location + copy: + remote_src: yes + src: '{{ pkgng_test_outofdate_pkg_tempfile.files[0].path }}' + dest: '{{ pkgng_test_outofdate_pkg_path }}' + +- name: Remove temporary directory + file: + state: absent + path: '{{ pkgng_test_outofdate_pkg_tempdir.path }}' diff --git a/tests/integration/targets/pkgng/tasks/freebsd.yml b/tests/integration/targets/pkgng/tasks/freebsd.yml new file mode 100644 index 0000000000..f5274d5c5d --- /dev/null +++ b/tests/integration/targets/pkgng/tasks/freebsd.yml @@ -0,0 +1,493 @@ +--- +## +## pkgng - prepare test environment +## +- name: Remove test package + pkgng: + name: '{{ pkgng_test_pkg_name }}' + state: absent + +## +## pkgng - example - state=present for single package +## +- name: 'state=present for single package' + include_tasks: install_single_package.yml + +## +## pkgng - example - state=latest for already up-to-date package +## +- name: Upgrade package (idempotent) + pkgng: + name: '{{ pkgng_test_pkg_name }}' + state: latest + register: pkgng_example2 + +- name: Ensure pkgng does not upgrade up-to-date package + assert: + that: + - not pkgng_example2.changed + +## +## pkgng - example - state=absent for single package +## +- name: Verify package sentinel file is present + stat: + path: '{{ pkgng_test_pkg_sentinelfile_path }}' + get_attributes: no + get_checksum: no + get_mime: no + register: pkgng_example3_stat_before + +- name: Install package (checkmode) + pkgng: + name: '{{ pkgng_test_pkg_name }}' + check_mode: yes + register: pkgng_example3_checkmode + +- name: Remove package + pkgng: + name: '{{ pkgng_test_pkg_name }}' + state: absent + register: pkgng_example3 + +- name: Remove package (idempotent) + pkgng: + name: '{{ pkgng_test_pkg_name }}' + state: absent + register: pkgng_example3_idempotent + +- name: Verify package sentinel file is not present + stat: + path: '{{ pkgng_test_pkg_sentinelfile_path }}' + get_attributes: no + get_checksum: no + get_mime: no + register: pkgng_example3_stat_after + +- name: Ensure pkgng installs package correctly + assert: + that: + - pkgng_example3_stat_before.stat.exists + - pkgng_example3_stat_before.stat.executable + - not pkgng_example3_checkmode.changed + - pkgng_example3.changed + - not pkgng_example3_idempotent.changed + - not pkgng_example3_stat_after.stat.exists + +## +## pkgng - example - state=latest for out-of-date package +## +- name: Install intentionally out-of-date package and upgrade it + # + # NOTE: The out-of-date package provided is a minimal, + # no-contents test package that declares {{ pkgng_test_pkg_name }} with + # a version of 0, so it should always be upgraded. + # + # This test might fail at some point in the + # future if the FreeBSD package format receives + # breaking changes that prevent pkg from installing + # older package formats. + # + block: + - name: Create out-of-date test package + import_tasks: create-outofdate-pkg.yml + + - name: Install out-of-date test package + command: 'pkg add {{ pkgng_test_outofdate_pkg_path }}' + register: pkgng_example4_prepare + + - name: Check for any available package upgrades (checkmode) + pkgng: + name: '*' + state: latest + check_mode: yes + register: pkgng_example4_wildcard_checkmode + + - name: Check for available package upgrade (checkmode) + pkgng: + name: '{{ pkgng_test_pkg_name }}' + state: latest + check_mode: yes + register: pkgng_example4_checkmode + + - name: Upgrade out-of-date package + pkgng: + name: '{{ pkgng_test_pkg_name }}' + state: latest + register: pkgng_example4 + + - name: Upgrade out-of-date package (idempotent) + pkgng: + name: '{{ pkgng_test_pkg_name }}' + state: latest + register: pkgng_example4_idempotent + + - name: Remove test out-of-date package + pkgng: + name: '{{ pkgng_test_pkg_name }}' + state: absent + + - name: Ensure pkgng upgrades package correctly + assert: + that: + - not pkgng_example4_prepare.failed + - pkgng_example4_wildcard_checkmode.changed + - pkgng_example4_checkmode.changed + - pkgng_example4.changed + - not pkgng_example4_idempotent.changed + +## +## pkgng - example - Install multiple packages in one command +## +- name: Remove test package (checkmode) + pkgng: + name: '{{ pkgng_test_pkg_name }}' + state: absent + check_mode: yes + register: pkgng_example5_prepare + +- name: Install three packages + pkgng: + name: + - '{{ pkgng_test_pkg_name }}' + - fish + - busybox + register: pkgng_example5 + +- name: Remove three packages + pkgng: + name: + - '{{ pkgng_test_pkg_name }}' + - fish + - busybox + state: absent + register: pkgng_example5_cleanup + +- name: Ensure pkgng installs multiple packages with one command + assert: + that: + - not pkgng_example5_prepare.changed + - pkgng_example5.changed + - '(pkgng_example5.stdout | regex_search("^Number of packages to be installed: (\d+)", "\\1", multiline=True) | first | int) >= 3' + - '(pkgng_example5.stdout | regex_findall("^Number of packages to be", multiline=True) | count) == 1' + - pkgng_example5_cleanup.changed + +## +## pkgng - example - state=latest multiple packages, some already installed +## +- name: Remove test package (checkmode) + pkgng: + name: '{{ pkgng_test_pkg_name }}' + state: absent + check_mode: yes + register: pkgng_example6_check + +- name: Create out-of-date test package + import_tasks: create-outofdate-pkg.yml + +- name: Install out-of-date test package + command: 'pkg add {{ pkgng_test_outofdate_pkg_path }}' + register: pkgng_example6_prepare + +- name: Upgrade and/or install two packages + pkgng: + name: + - '{{ pkgng_test_pkg_name }}' + - fish + state: latest + register: pkgng_example6 + +- name: Remove two packages + pkgng: + name: + - '{{ pkgng_test_pkg_name }}' + - fish + state: absent + register: pkgng_example6_cleanup + +- name: Ensure pkgng installs multiple packages with one command + assert: + that: + - not pkgng_example6_check.changed + - not pkgng_example6_prepare.failed + - pkgng_example6.changed + - '(pkgng_example6.stdout | regex_search("^Number of packages to be installed: (\d+)", "\\1", multiline=True) | first | int) >= 1' + - '(pkgng_example6.stdout | regex_search("^Number of packages to be upgraded: (\d+)", "\\1", multiline=True) | first | int) >= 1' + # Checking that "will be affected" occurs twice in the output ensures + # that the module runs two separate commands for install and upgrade, + # as the pkg command only outputs the string once per invocation. + - '(pkgng_example6.stdout | regex_findall("will be affected", multiline=True) | count) == 2' + - pkgng_example6_cleanup.changed + +## +## pkgng - example - autoremove=yes +## +- name: "Test autoremove=yes" + # + # NOTE: FreeBSD 12.0 test runner receives a "connection reset by peer" after ~20% downloaded so we are + # only running this on 12.1 or higher + # + when: ansible_distribution_version is version('12.01', '>=') + block: + - name: Install GNU autotools + pkgng: + name: autotools + state: latest + register: pkgng_example7_prepare_install + + - name: Remove GNU autotools and run pkg autoremove + pkgng: + name: autotools + state: absent + autoremove: yes + register: pkgng_example7 + + - name: Check if autoremove uninstalled known autotools dependencies + pkgng: + name: + - autoconf + - automake + - libtool + - m4 + state: absent + check_mode: yes + register: pkgng_example7_cleanup + + - name: Ensure pkgng autoremove works correctly + assert: + that: + - pkgng_example7_prepare_install.changed + - "'autoremoved' is in(pkgng_example7.msg)" + - not pkgng_example7_cleanup.changed + +## +## pkgng - example - single annotations +## +- name: Install and annotate single package + pkgng: + name: '{{ pkgng_test_pkg_name }}' + annotation: '+ansibletest_example8=added' + register: pkgng_example8_add_annotation + +- name: Should fail to add duplicate annotation + pkgng: + name: '{{ pkgng_test_pkg_name }}' + annotation: '+ansibletest_example8=duplicate' + ignore_errors: yes + register: pkgng_example8_add_annotation_failure + +- name: Verify annotation is actually there + command: 'pkg annotate -q -S {{ pkgng_test_pkg_name }} ansibletest_example8' + register: pkgng_example8_add_annotation_verify + +- name: Install and annotate single package (checkmode, not changed) + pkgng: + name: '{{ pkgng_test_pkg_name }}' + annotation: '+ansibletest_example8=added' + check_mode: yes + register: pkgng_example8_add_annotation_checkmode_nochange + +- name: Install and annotate single package (checkmode, changed) + pkgng: + name: '{{ pkgng_test_pkg_name }}' + annotation: '+ansibletest_example8_checkmode=added' + check_mode: yes + register: pkgng_example8_add_annotation_checkmode_change + +- name: Verify check_mode did not add an annotation + command: 'pkg annotate -q -S {{ pkgng_test_pkg_name }} ansibletest_example8_checkmode' + register: pkgng_example8_add_annotation_checkmode_change_verify + +- name: Modify annotation on single package + pkgng: + name: '{{ pkgng_test_pkg_name }}' + annotation: ':ansibletest_example8=modified' + register: pkgng_example8_modify_annotation + +- name: Should fail to modify missing annotation + pkgng: + name: '{{ pkgng_test_pkg_name }}' + annotation: ':ansiblemissing=modified' + ignore_errors: yes + register: pkgng_example8_modify_annotation_failure + +- name: Verify annotation has been modified + command: 'pkg annotate -q -S {{ pkgng_test_pkg_name }} ansibletest_example8' + register: pkgng_example8_modify_annotation_verify + +- name: Remove annotation on single package + pkgng: + name: '{{ pkgng_test_pkg_name }}' + annotation: '-ansibletest_example8' + register: pkgng_example8_remove_annotation + +- name: Verify annotation has been removed + command: 'pkg annotate -q -S {{ pkgng_test_pkg_name }} ansibletest_example8' + register: pkgng_example8_remove_annotation_verify + +- name: Ensure pkgng annotations on single packages work correctly + assert: + that: + - pkgng_example8_add_annotation.changed + - pkgng_example8_add_annotation_failure.failed + - pkgng_example8_add_annotation_checkmode_nochange is not changed + - pkgng_example8_add_annotation_checkmode_change is changed + - 'pkgng_example8_add_annotation_checkmode_change_verify.stdout_lines | count == 0' + - 'pkgng_example8_add_annotation_verify.stdout_lines | first == "added"' + - pkgng_example8_modify_annotation.changed + - pkgng_example8_modify_annotation_failure.failed + - 'pkgng_example8_modify_annotation_verify.stdout_lines | first == "modified"' + - pkgng_example8_remove_annotation.changed + - 'pkgng_example8_remove_annotation_verify.stdout_lines | count == 0' + +## +## pkgng - example - multiple annotations +## +- name: Annotate single package with multiple annotations + pkgng: + name: '{{ pkgng_test_pkg_name }}' + annotation: + - '+ansibletest_example9_1=added' + - '+ansibletest_example9_2=added' + register: pkgng_example9_add_annotation + +- name: Verify annotation is actually there + command: 'pkg info -q -A {{ pkgng_test_pkg_name }}' + register: pkgng_example9_add_annotation_verify + # Assert, below, tests that stdout includes: + # ``` + # ansibletest_example9_1 : added + # ansibletest_example9_2 : added + # ``` + +- name: Multiple annotation operations on single package + pkgng: + name: '{{ pkgng_test_pkg_name }}' + annotation: + - ':ansibletest_example9_1=modified' + - '+ansibletest_example9_3=added' + register: pkgng_example9_multiple_annotation + +- name: Verify multiple operations succeeded + command: 'pkg info -q -A {{ pkgng_test_pkg_name }}' + register: pkgng_example9_multiple_annotation_verify + # Assert, below, tests that stdout includes: + # ``` + # ansibletest_example9_1 : modified + # ansibletest_example9_2 : added + # ansibletest_example9_3 : added + # ``` + +- name: Add multiple annotations with old syntax + pkgng: + name: '{{ pkgng_test_pkg_name }}' + annotation: '+ansibletest_example9_4=added,+ansibletest_example9_5=added' + register: pkgng_example9_add_annotation_old + +- name: Verify annotation is actually there + command: 'pkg info -q -A {{ pkgng_test_pkg_name }}' + register: pkgng_example9_add_annotation_old_verify + # Assert, below, tests that stdout includes: + # ``` + # ansibletest_example9_4 : added + # ansibletest_example9_5 : added + # ``` + +- name: Ensure multiple annotations work correctly + assert: + that: + - pkgng_example9_add_annotation.changed + - '(pkgng_example9_add_annotation_verify.stdout_lines | select("match", "ansibletest_example9_[12]\s*:\s*added") | list | count) == 2' + - pkgng_example9_multiple_annotation.changed + - '(pkgng_example9_multiple_annotation_verify.stdout_lines | select("match", "ansibletest_example9_1\s*:\s*modified") | list | count) == 1' + - '(pkgng_example9_multiple_annotation_verify.stdout_lines | select("match", "ansibletest_example9_[23]\s*:\s*added") | list | count) == 2' + - pkgng_example9_add_annotation_old.changed + - '(pkgng_example9_add_annotation_old_verify.stdout_lines | select("match", "ansibletest_example9_[45]\s*:\s*added") | list | count) == 2' + +## +## pkgng - example - invalid annotation strings +## +- name: Should fail on invalid annotate strings + pkgng: + name: '{{ pkgng_test_pkg_name }}' + annotation: '{{ item }}' + ignore_errors: yes + register: pkgng_example8_invalid_annotation_failure + loop: + - 'naked_string' + - '/invalid_operation' + - ',empty_first_tag=validsecond' + - '=notag' + +- name: Verify invalid annotate strings did not add annotations + command: 'pkg info -q -A {{ pkgng_test_pkg_name }}' + register: pkgng_example8_invalid_annotation_verify + +- name: Ensure invalid annotate strings fail safely + assert: + that: + # Invalid strings should not change anything + - '(pkgng_example8_invalid_annotation_failure.results | selectattr("changed") | list | count) == 0' + # Invalid strings should always fail + - '(pkgng_example8_invalid_annotation_failure.results | rejectattr("failed") | list | count) == 0' + # Invalid strings should not cause an exception + - '(pkgng_example8_invalid_annotation_failure.results | selectattr("exception", "defined") | list | count) == 0' + # Verify annotations are unaffected + - '(pkgng_example8_invalid_annotation_verify.stdout_lines | select("search", "(naked_string|invalid_operation|empty_first_tag|validsecond|notag)") | list | count) == 0' + +## +## pkgng - example - pkgsite=... +## +# NOTE: testing for failure here to not have to set up our own +# or depend on a third-party, alternate package repo +- name: Should fail with invalid pkgsite + pkgng: + name: '{{ pkgng_test_pkg_name }}' + pkgsite: DoesNotExist + ignore_errors: yes + register: pkgng_example10_invalid_pkgsite_failure + +- name: Ensure invalid pkgsite fails as expected + assert: + that: + - pkgng_example10_invalid_pkgsite_failure.failed + - 'pkgng_example10_invalid_pkgsite_failure.stdout is search("^No repositories are enabled.", multiline=True)' + +## +## pkgng - example - Install single package in jail +## +- name: Test within jail + # + # NOTE: FreeBSD 12.0 test runner receives a "connection reset by peer" after ~20% downloaded so we are + # only running this on 12.1 or higher + # + when: ansible_distribution_version is version('12.01', '>=') + block: + - name: Setup testjail + include: setup-testjail.yml + + - name: Install package in jail as rootdir + include_tasks: install_single_package.yml + vars: + pkgng_test_rootdir: /usr/jails/testjail + pkgng_test_install_prefix: /usr/jails/testjail + pkgng_test_install_cleanup: yes + + - name: Install package in jail + include_tasks: install_single_package.yml + vars: + pkgng_test_jail: testjail + pkgng_test_install_prefix: /usr/jails/testjail + pkgng_test_install_cleanup: yes + + - name: Install package in jail as chroot + include_tasks: install_single_package.yml + vars: + pkgng_test_chroot: /usr/jails/testjail + pkgng_test_install_prefix: /usr/jails/testjail + pkgng_test_install_cleanup: yes + always: + - name: Stop and remove testjail + failed_when: false + changed_when: false + command: "ezjail-admin delete -wf testjail" diff --git a/tests/integration/targets/pkgng/tasks/install_single_package.yml b/tests/integration/targets/pkgng/tasks/install_single_package.yml new file mode 100644 index 0000000000..7115b8a8a1 --- /dev/null +++ b/tests/integration/targets/pkgng/tasks/install_single_package.yml @@ -0,0 +1,54 @@ +--- +- name: Verify package sentinel file is not present + stat: + path: '{{ pkgng_test_install_prefix | default("") }}{{ pkgng_test_pkg_sentinelfile_path }}' + get_attributes: no + get_checksum: no + get_mime: no + register: pkgng_install_stat_before + +- name: Install package + pkgng: &pkgng_install_params + name: '{{ pkgng_test_pkg_name }}' + jail: '{{ pkgng_test_jail | default(omit) }}' + chroot: '{{ pkgng_test_chroot | default(omit) }}' + rootdir: '{{ pkgng_test_rootdir | default(omit) }}' + register: pkgng_install + +- name: Remove package (checkmode) + pkgng: + <<: *pkgng_install_params + state: absent + check_mode: yes + register: pkgng_install_checkmode + +- name: Install package (idempotent, cached) + pkgng: + <<: *pkgng_install_params + cached: yes + register: pkgng_install_idempotent_cached + +- name: Verify package sentinel file is present + stat: + path: '{{ pkgng_test_install_prefix | default("") }}{{ pkgng_test_pkg_sentinelfile_path }}' + get_attributes: no + get_checksum: no + get_mime: no + register: pkgng_install_stat_after + +- name: Remove test package (if requested) + pkgng: + <<: *pkgng_install_params + state: absent + when: 'pkgng_test_install_cleanup | default(False)' + +- name: Ensure pkgng installs package correctly + assert: + that: + - not pkgng_install_stat_before.stat.exists + - pkgng_install.changed + - pkgng_install_checkmode.changed + - not pkgng_install_idempotent_cached.changed + - not pkgng_install_idempotent_cached.stdout is match("Updating \w+ repository catalogue\.\.\.") + - pkgng_install_stat_after.stat.exists + - pkgng_install_stat_after.stat.executable diff --git a/tests/integration/targets/pkgng/tasks/main.yml b/tests/integration/targets/pkgng/tasks/main.yml new file mode 100644 index 0000000000..d9e340bd40 --- /dev/null +++ b/tests/integration/targets/pkgng/tasks/main.yml @@ -0,0 +1,4 @@ +--- +- import_tasks: freebsd.yml + when: + - ansible_facts.distribution == 'FreeBSD' diff --git a/tests/integration/targets/pkgng/tasks/setup-testjail.yml b/tests/integration/targets/pkgng/tasks/setup-testjail.yml new file mode 100644 index 0000000000..22130745ef --- /dev/null +++ b/tests/integration/targets/pkgng/tasks/setup-testjail.yml @@ -0,0 +1,96 @@ +--- +# +# Instructions for setting up a jail +# https://www.freebsd.org/doc/en_US.ISO8859-1/books/handbook/jails-ezjail.html +# +- name: Setup cloned interfaces + lineinfile: + dest: /etc/rc.conf + regexp: ^cloned_interfaces=lo1 + line: cloned_interfaces=lo1 + +- name: Activate cloned interfaces + command: "service netif cloneup" + changed_when: false + +- name: Add nat rule for cloned interfaces + copy: + dest: /etc/pf.conf + content: | + nat on {{ ansible_default_ipv4.interface }} from 127.0.1.0/24 -> {{ ansible_default_ipv4.interface }}:0 + validate: "pfctl -nf %s" + +- name: Start pf firewall + service: + name: pf + state: started + enabled: yes + +- name: Install ezjail + pkgng: + name: ezjail + +- name: Configure ezjail to use http + when: ansible_distribution_version is version('11.01', '>') + lineinfile: + dest: /usr/local/etc/ezjail.conf + regexp: ^ezjail_ftphost + line: ezjail_ftphost=http://ftp.freebsd.org + +- name: Configure ezjail to use archive for old freebsd releases + when: ansible_distribution_version is version('11.01', '<=') + lineinfile: + dest: /usr/local/etc/ezjail.conf + regexp: ^ezjail_ftphost + line: ezjail_ftphost=http://ftp-archive.freebsd.org + +- name: Start ezjail + ignore_errors: yes + service: + name: ezjail + state: started + enabled: yes + +- name: Redirect logs depending on verbosity + set_fact: + pkgng_jail_log_redirect: "2>&1 | tee -a /tmp/ezjail.log {{ '> /dev/null' if ansible_verbosity < 2 else '' }}" + +- name: Has ezjail + register: ezjail_base_jail + stat: + path: /usr/jails/basejail + +- name: Setup ezjail base + when: not ezjail_base_jail.stat.exists + shell: "ezjail-admin install {{ pkgng_jail_log_redirect }}" + changed_when: false + +- name: Has testjail + register: ezjail_test_jail + stat: + path: /usr/jails/testjail + +- name: Create testjail + when: not ezjail_test_jail.stat.exists + shell: "ezjail-admin create testjail 'lo1|127.0.1.1' {{ pkgng_jail_log_redirect }}" + changed_when: false + +- name: Configure testjail to use Cloudflare DNS + lineinfile: + dest: /usr/jails/testjail/etc/resolv.conf + regexp: "^nameserver[[:blank:]]+{{ item }}$" + line: "nameserver {{ item }}" + create: yes + loop: + - "1.1.1.1" + - "1.0.0.1" + +- name: Is testjail running + shell: "jls | grep testjail" + changed_when: false + failed_when: false + register: is_testjail_up + +- name: Start testjail + when: is_testjail_up.rc == 1 + command: "ezjail-admin start testjail" diff --git a/tests/integration/targets/pkgng/templates/MANIFEST.json.j2 b/tests/integration/targets/pkgng/templates/MANIFEST.json.j2 new file mode 100644 index 0000000000..e8537e89bf --- /dev/null +++ b/tests/integration/targets/pkgng/templates/MANIFEST.json.j2 @@ -0,0 +1,16 @@ +{ + "name": "{{ pkgng_test_pkg_name }}", + "origin": "{{ pkgng_test_pkg_category }}/{{ pkgng_test_pkg_name }}", + "version": "{{ pkgng_test_pkg_version | default('0') }}", + "comment": "{{ pkgng_test_pkg_name }} (Ansible Integration Test Package)", + "maintainer": "ansible-devel@googlegroups.com", + "www": "https://github.com/ansible-collections/community.general", + "abi": "FreeBSD:*:*", + "arch": "freebsd:*:*", + "prefix": "/usr/local", + "flatsize":0, + "licenselogic": "single", + "licenses":["GPLv3"], + "desc": "This package is only installed temporarily for integration testing of the community.general.pkgng Ansible module.\nIts version number is 0 so that ANY version of the real package, with the same name, will be considered an upgrade.\nIts architecture and abi are FreeBSD:*:* so that it will install on any version or architecture of FreeBSD,\nthus future-proof as long as the package MANIFEST format does not change\nand a wildcard in the version portion of the abi or arch field is not prohibited.", + "categories":["{{ pkgng_test_pkg_category }}"] +} diff --git a/tests/integration/targets/pkgng/vars/main.yml b/tests/integration/targets/pkgng/vars/main.yml new file mode 100644 index 0000000000..d5aca65cdd --- /dev/null +++ b/tests/integration/targets/pkgng/vars/main.yml @@ -0,0 +1,5 @@ +--- +pkgng_test_outofdate_pkg_path: "/tmp/ansible_pkgng_test_package.pkg" +pkgng_test_pkg_name: zsh +pkgng_test_pkg_category: shells +pkgng_test_pkg_sentinelfile_path: /usr/local/bin/zsh