Repository: ansible-collections/community.general

Issue:
pkgng doesn't join package list into one command
### Summary

On at least modern FreeBSD (untested on older FreeBSD), the `pkg` command accepts multiple packages.
Currently the `pkgng` module [loops through](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/packaging/os/pkgng.py#L261) each package and [runs](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/packaging/os/pkgng.py#L275-L278) the `pkg` command each time.

There are some advantages to installing them in one go: Consistency with how other (and core) package manager modules work, not getting into an unexpected state (when one package gets installed, but the next fails to install), likely a small speed increase, etc.

This is just a low-priority/nicetohave, feature request to join the package list and call `pkg` once, when possible.

The following shows that it works with multiple packages:

```
root@hosted02:~ # pkg install -g -U -y vim htop
Checking integrity... done (0 conflicting)
The following 2 package(s) will be affected (of 0 checked):

New packages to be INSTALLED:
	htop: 3.0.4_1
	vim: 8.2.2725

Number of packages to be installed: 2

The process will require 35 MiB more space.
[1/2] Installing vim-8.2.2725...
[1/2] Extracting vim-8.2.2725: 100%
[2/2] Installing htop-3.0.4_1...
[2/2] Extracting htop-3.0.4_1: 100%
```

### Issue Type

Feature Idea

### Component Name

pkgng

### Additional Information

_No response_

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/packaging/os/pkgng.py`](https://github.com/ansible-collections/community.general/blob/master/plugins/modules/packaging/os/pkgng.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @JoergFiedler @MacLemon @bcoca @dch @jasperla @mekanix @opoplawski @overhacked @tuxillo
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/plugins/modules/packaging/os/pkgng.py b/plugins/modules/packaging/os/pkgng.py
--- a/plugins/modules/packaging/os/pkgng.py
+++ b/plugins/modules/packaging/os/pkgng.py
@@ -134,6 +134,7 @@
 '''
 
 
+from collections import defaultdict
 import re
 from ansible.module_utils.basic import AnsibleModule
 
@@ -226,7 +227,8 @@ def remove_packages(module, pkgng_path, packages, dir_arg):
 
 
 def install_packages(module, pkgng_path, packages, cached, pkgsite, dir_arg, state, ignoreosver):
-    install_c = 0
+    action_queue = defaultdict(list)
+    action_count = defaultdict(int)
     stdout = ""
     stderr = ""
 
@@ -263,29 +265,48 @@ def install_packages(module, pkgng_path, packages, cached, pkgsite, dir_arg, sta
         if already_installed and state == "present":
             continue
 
-        update_available = query_update(module, pkgng_path, package, dir_arg, old_pkgng, pkgsite)
-        if not update_available and already_installed and state == "latest":
+        if (
+            already_installed and state == "latest"
+            and not query_update(module, pkgng_path, package, dir_arg, old_pkgng, pkgsite)
+        ):
             continue
 
-        if not module.check_mode:
-            if already_installed:
-                action = "upgrade"
-            else:
-                action = "install"
+        if already_installed:
+            action_queue["upgrade"].append(package)
+        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, package))
+                rc, out, err = module.run_command("%s %s %s %s -g -U -y %s" % (batch_var, pkgsite, pkgng_path, action, packages))
             else:
-                rc, out, err = module.run_command("%s %s %s %s %s -g -U -y %s" % (batch_var, pkgng_path, dir_arg, action, pkgsite, package))
+                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
 
-        if not module.check_mode and not query_package(module, pkgng_path, package, dir_arg):
-            module.fail_json(msg="failed to %s %s: %s" % (action, package, out), stdout=stdout, stderr=stderr)
-
-        install_c += 1
-
-    if install_c > 0:
-        return (True, "added %s package(s)" % (install_c), stdout, stderr)
+            # 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)
+
+    if sum(action_count.values()) > 0:
+        past_tense = {'install': 'installed', 'upgrade': 'upgraded'}
+        messages = []
+        for (action, count) in action_count.items():
+            messages.append("%s %s package%s" % (past_tense.get(action, action), count, "s" if count != 1 else ""))
+
+        return (True, '; '.join(messages), stdout, stderr)
 
     return (False, "package(s) already %s" % (state), stdout, stderr)
 


Test patch: