Repository: ansible/ansible-lint

Issue:
inline-env-var reported when cmd and argv are specified in ansible.builtin.command
<!--- Verify first that your issue is not already reported on GitHub -->
<!--- Also test if the latest release and main branch are affected too -->

##### Summary

<!--- Explain the problem briefly below -->

When specifying `cmd` and `argv` in an `ansible.builtin.command`, the inline-env-var rule will trigger, causing a confusing error (while still "detecting" a problematic task).

##### Issue Type

- Bug Report

##### OS / ENVIRONMENT

<!--- Paste verbatim output between triple backticks -->
I tested this on the main branch, c89815bd790b45ab713f65406b9f496c8ca79e56, installing everything in a fresh virtual environment in python 3.13.3 via `pip install -e '.[test]'`.
```console (paste below)
$ ansible-lint --version
ansible-lint 25.1.3 using ansible-core:2.18.3 ansible-compat:25.1.4 ruamel-yaml:0.18.10 ruamel-yaml-clib:0.2.12
```

<!--- Provide all relevant information below, e.g. target OS versions, network
 device firmware, etc. -->

- ansible installation method: one of source, pip, OS package
- ansible-lint installation method: one of source, pip, OS package

##### STEPS TO REPRODUCE

<!--- Describe exactly how to reproduce the problem, using a minimal test case -->
The following task specification is wrong (cmd and argv are mutually exclusive, see also https://docs.ansible.com/ansible/latest/collections/ansible/builtin/command_module.html#parameter-argv):
```yaml
  - name: Command with cmd and argv
    ansible.builtin.command:
      cmd: my_command
      argv:
         - argument
```
It does not contain any =, environment variables, or anything – still, ansible-lint reports `inline-env-var`. (You can add this to the `SUCCESS_PLAY_TASKS` in test/rules/test_inline_env_var.py to test it:

```
>       assert len(results) == 0
E       assert 1 == 0
E        +  where 1 = len([[inline-env-var] (Command module does not accept setting environment variables inline.) matched /private/var/folders/sd/ng9xryz96pqct9xwg4dc34q80000gn/T/playbookx0toxpdp.yml:57 Task/Handler: Command with cmd and argv])
```


##### Desired Behavior

<!--- Describe what you expected to happen when running the steps above -->

Maybe a report of "cmd and argv cannot be specified together"/"are mutually exclusive", but not an inline-env-var false-positive.

##### Actual Behavior

See above, the task spec can be added to test/rules/test_inline_env_var.py to trigger the behavior.
output of ansible-lint for such a task is:
```
inline-env-var: Command module does not accept setting environment variables inline.
roles/my_role/tasks/main.yml:1 Task/Handler: Command with cmd and argv

Read documentation for instructions on how to ignore specific rule violations.

# Rule Violation Summary

  1 inline-env-var profile:basic tags:command-shell,idiom

Failed: 1 failure(s), 0 warning(s) on 1 files. Last profile that met the validation criteria was 'min'.
```

If you like, I can also offer a fix for this, but I wasn't sure how to resolve this.

I noticed that "expected_args" in src/ansiblelint/rules/inline_env_var.py does not include `argv`, and adding that resolves this false-positive.
However, there are two things I was not sure about; first, there are already examples containing argv for which the rule does not trigger in the tests:
```
  - name: Use argv to send the command as a list
    command:
      argv:
        - /bin/echo
        - Hello
        - World

  - name: Another use of argv
    command:
    args:
      argv:
        - echo
        - testing
```
And second, maybe there should be a rule to warn about cmd + argv in a command task?


Discussion:

Patch:
diff --git a/examples/playbooks/rule-args-module-fail.yml b/examples/playbooks/rule-args-module-fail.yml
index 25371fbac5..9876358010 100644
--- a/examples/playbooks/rule-args-module-fail.yml
+++ b/examples/playbooks/rule-args-module-fail.yml
@@ -35,3 +35,11 @@
         path: /opt/software/deployment
         state: away
         mode: "0600"
+
+    - name: Mutually exclusive cmd and argv
+      # module should produce: 'arguments {"cmd", "argv"} are mutually exclusive'
+      ansible.builtin.command:
+        cmd: /bin/echo
+        argv:
+          - Hello
+      changed_when: false
diff --git a/src/ansiblelint/rules/args.md b/src/ansiblelint/rules/args.md
index 567d0fdb98..e03f8fc833 100644
--- a/src/ansiblelint/rules/args.md
+++ b/src/ansiblelint/rules/args.md
@@ -40,6 +40,13 @@ Possible messages:
           - my_param <= 100
           - my_param >= 0
         quiet: invalid # <- Value for option `quiet` is invalid.
+
+    - name: Do not use mutually exclusive arguments together
+      ansible.builtin.command:
+        cmd: /bin/echo # <- cmd and argv are mutually exclusive options
+        argv:
+          - Hello
+      when_changed: false
 ```
 
 ## Correct Code
@@ -69,6 +76,18 @@ Possible messages:
           - my_param <= 100
           - my_param >= 0
         quiet: True # <- Has correct type value for option `quiet` which is boolean.
+
+    - name: Do not use mutually exclusive arguments together
+      ansible.builtin.command:
+        cmd: "/bin/echo Hello" # <- Does not use `cmd` and `argv`
+      when_changed: false
+
+    - name: Do not use mutually exclusive arguments together alternative
+      ansible.builtin.command:
+        argv: # <- Does not use `cmd` and `argv`
+          - /bin/echo
+          - Hello
+      when_changed: false
 ```
 
 ## Special cases
diff --git a/src/ansiblelint/rules/args.py b/src/ansiblelint/rules/args.py
index 76d786c7f7..f412314e96 100644
--- a/src/ansiblelint/rules/args.py
+++ b/src/ansiblelint/rules/args.py
@@ -64,6 +64,10 @@
     # https://github.com/ansible/ansible-lint/issues/2824
     "ansible.builtin.async_status": {"_async_dir": "/tmp/ansible-async"},
 }
+workarounds_mutex_args_map = {
+    # https://github.com/ansible/ansible-lint/issues/4623
+    "ansible.builtin.command": [{"cmd", "argv"}],
+}
 
 
 class ValidationPassedError(Exception):
@@ -136,6 +140,19 @@ def matchtask(
 
         if loaded_module.resolved_fqcn in workarounds_inject_map:
             module_args.update(workarounds_inject_map[loaded_module.resolved_fqcn])
+        if loaded_module.resolved_fqcn in workarounds_mutex_args_map:
+            results.extend(
+                self.create_matcherror(
+                    message=f"Module arguments {mutex_set} are mutually exclusive",
+                    lineno=task.line,
+                    tag="args[module]",
+                    filename=file,
+                )
+                for mutex_set in workarounds_mutex_args_map[loaded_module.resolved_fqcn]
+                if len(mutex_set - module_args.keys()) < len(mutex_set) - 1
+            )
+            if results:
+                return self._sanitize_results(results, module_name)
         if loaded_module.resolved_fqcn in workarounds_drop_map:
             for key in workarounds_drop_map[loaded_module.resolved_fqcn]:
                 module_args.pop(key, None)
@@ -287,7 +304,7 @@ def test_args_module_fail(default_rules_collection: RulesCollection) -> None:
         """Test rule invalid module options."""
         success = "examples/playbooks/rule-args-module-fail.yml"
         results = Runner(success, rules=default_rules_collection).run()
-        assert len(results) == 5
+        assert len(results) == 6
         assert results[0].tag == "args[module]"
         # First part of regex is for ansible-core up to 2.18, second part is for ansible-core 2.19+
         assert re.match(
@@ -311,6 +328,8 @@ def test_args_module_fail(default_rules_collection: RulesCollection) -> None:
         )
         assert results[4].tag == "args[module]"
         assert "value of state must be one of" in results[4].message
+        assert results[5].tag == "args[module]"
+        assert "are mutually exclusive" in results[5].message
 
     def test_args_module_pass(
         default_rules_collection: RulesCollection,
diff --git a/src/ansiblelint/rules/inline_env_var.py b/src/ansiblelint/rules/inline_env_var.py
index ff15b24ce3..fcc2a4a697 100644
--- a/src/ansiblelint/rules/inline_env_var.py
+++ b/src/ansiblelint/rules/inline_env_var.py
@@ -44,6 +44,7 @@ class EnvVarsInCommandRule(AnsibleLintRule):
     version_changed = "5.0.11"
 
     expected_args = [
+        "argv",
         "chdir",
         "creates",
         "executable",


Test patch:
diff --git a/test/rules/test_inline_env_var.py b/test/rules/test_inline_env_var.py
index aa833ec5b1..215e3fbfba 100644
--- a/test/rules/test_inline_env_var.py
+++ b/test/rules/test_inline_env_var.py
@@ -59,6 +59,12 @@
     command: echo
     args:
       strip_empty_ends: false
+
+  - name: Mutually exclusive cmd and argv should not trigger a false-positive
+    ansible.builtin.command:
+      cmd: /bin/echo
+      argv:
+         - Hello
 """
 
 FAIL_PLAY_TASKS = """