From 10f86cff19a546ccf9bee35c3c629256765fa28f Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:18:39 +0200 Subject: [PATCH] [PR #12163/f9d4f0ad backport][stable-12] Fix incus Windows modules with ansible-core 2.21 (#12178) Fix incus Windows modules with ansible-core 2.21 (#12163) * Fix incus Windows modules with ansible-core 2.21 * strip wrapper quotes for payload flags (-enc, -encodedcommand, -command, -c, -file, -f) before incus exec argv handoff, added changelogs * Fixed some edge cases for powershell parsing * Fixed changelogs * Fixed pep8 format * Added warning message when modifying direct commands * Fixed changelogs fragement (cherry picked from commit f9d4f0ad6b05e0de3e6afb431047ec4a8e9ba591) Co-authored-by: Simon Bouchard --- .../12158-incus-windows-ansible-core-221.yml | 3 + plugins/connection/incus.py | 44 +++-- tests/unit/plugins/connection/test_incus.py | 150 ++++++++++++++++++ 3 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 changelogs/fragments/12158-incus-windows-ansible-core-221.yml diff --git a/changelogs/fragments/12158-incus-windows-ansible-core-221.yml b/changelogs/fragments/12158-incus-windows-ansible-core-221.yml new file mode 100644 index 0000000000..0408234bb5 --- /dev/null +++ b/changelogs/fragments/12158-incus-windows-ansible-core-221.yml @@ -0,0 +1,3 @@ +bugfixes: + - "incus connection plugin - improve Windows PowerShell argv handling by stripping wrapper quotes from payload arguments for ``-enc``, ``-encodedcommand``, ``-command``, ``-c``, ``-file`` and ``-f`` (https://github.com/ansible-collections/community.general/issues/12161, https://github.com/ansible-collections/community.general/pull/12158)." + - "incus connection plugin - return ``stdout``/``stderr`` as bytes instead of strings to restore compatibility with ansible-core 2.21 module execution (https://github.com/ansible-collections/community.general/issues/12161, https://github.com/ansible-collections/community.general/pull/12158)." diff --git a/plugins/connection/incus.py b/plugins/connection/incus.py index 5fe8523d03..3119d851da 100644 --- a/plugins/connection/incus.py +++ b/plugins/connection/incus.py @@ -79,11 +79,12 @@ options: import os import re +import shlex from subprocess import PIPE, Popen, call from ansible.errors import AnsibleConnectionFailure, AnsibleError, AnsibleFileNotFound from ansible.module_utils.common.process import get_bin_path -from ansible.module_utils.common.text.converters import to_bytes, to_text +from ansible.module_utils.common.text.converters import to_bytes from ansible.plugins.connection import ConnectionBase @@ -163,13 +164,37 @@ class Connection(ConnectionBase): exec_cmd.append(regex_match.group("executable")) if args := regex_match.group("args"): exec_cmd.extend(args.strip().split(" ")) + # Set the command argument depending on cmd or powershell and the rest of it exec_cmd.append(regex_match.group("command")) + if post_args := regex_match.group("post_args"): - exec_cmd.append(post_args.strip()) + post_args = post_args.strip() + + # Keep quotes from becoming literal PowerShell argument content. + if len(post_args) >= 2 and post_args[0] == post_args[-1] and post_args[0] in ("'", '"'): + self._display.v( + "WARNING: PowerShell -Command argument is wrapped in outer quotes; " + "this connection plugin strips those quotes and behavior may differ " + "from a direct shell run. Prefer passing -Command without extra " + "outer quoting.", + host=self._instance(), + ) + post_args = post_args[1:-1] + + exec_cmd.append(post_args) else: - # For anything else using -EncodedCommand or else, just split on space. - exec_cmd.extend(cmd.split(" ")) + # Keep quotes from becoming literal PowerShell argument content. + # shlex is not a full PowerShell/Windows command-line parser, + # but with posix=False it reliably keeps quoted segments (for + # example, paths with spaces) as single argv items, which is + # what we need before passing arguments to incus exec. + parts = shlex.split(cmd, posix=False) + for i, part in enumerate(parts[:-1]): + if part.lower() in {"-enc", "-encodedcommand", "-file", "-f"}: + parts[i + 1] = parts[i + 1].strip("'\"") + break + exec_cmd.extend(parts) else: if self.get_option("remote_user") != "root": self._display.vvv( @@ -203,25 +228,22 @@ class Connection(ConnectionBase): process = Popen(local_cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) stdout, stderr = process.communicate(in_data) - stdout = to_text(stdout) - stderr = to_text(stderr) - - if stderr.startswith("Error: ") and stderr.rstrip().endswith(": Instance is not running"): + if stderr.startswith(b"Error: ") and stderr.rstrip().endswith(b": Instance is not running"): raise AnsibleConnectionFailure( f"instance not running: {self._instance()} (remote={self.get_option('remote')}, project={self.get_option('project')})" ) - if stderr.startswith("Error: ") and stderr.rstrip().endswith(": Instance not found"): + if stderr.startswith(b"Error: ") and stderr.rstrip().endswith(b": Instance not found"): raise AnsibleConnectionFailure( f"instance not found: {self._instance()} (remote={self.get_option('remote')}, project={self.get_option('project')})" ) - if stderr.startswith("Error: ") and ": User does not have permission " in stderr: + if stderr.startswith(b"Error: ") and b": User does not have permission " in stderr: raise AnsibleConnectionFailure( f"instance access denied: {self._instance()} (remote={self.get_option('remote')}, project={self.get_option('project')})" ) - if stderr.startswith("Error: ") and ": User does not have entitlement " in stderr: + if stderr.startswith(b"Error: ") and b": User does not have entitlement " in stderr: raise AnsibleConnectionFailure( f"instance access denied: {self._instance()} (remote={self.get_option('remote')}, project={self.get_option('project')})" ) diff --git a/tests/unit/plugins/connection/test_incus.py b/tests/unit/plugins/connection/test_incus.py index 91250d791d..2b7a40b67f 100644 --- a/tests/unit/plugins/connection/test_incus.py +++ b/tests/unit/plugins/connection/test_incus.py @@ -104,6 +104,156 @@ BUILD_CMD_TEST_CASES: list[dict[str, t.Any]] = [ ), output=[r"C:\CMD", "/c", "some-command /flag1 /flag2"], ), + dict( + id="powershell encoded command strips quotes", + input=dict( + cmd="""powershell -NoProfile -NonInteractive -ExecutionPolicy Unrestricted -EncodedCommand 'cABhAHIAYQBtAA=='""", + shell="powershell", + ), + output=[ + "powershell", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Unrestricted", + "-EncodedCommand", + "cABhAHIAYQBtAA==", + ], + ), + dict( + id="powershell encoded command strips double quotes", + input=dict( + cmd='''powershell -NoProfile -EncodedCommand "cABhAHIAYQBtAA=="''', + shell="powershell", + ), + output=[ + "powershell", + "-NoProfile", + "-EncodedCommand", + "cABhAHIAYQBtAA==", + ], + ), + dict( + id="powershell encoded command case-insensitive keyword", + input=dict( + cmd="""powershell -NoProfile -eNcOdEdCoMmAnD 'cABhAHIAYQBtAA=='""", + shell="powershell", + ), + output=[ + "powershell", + "-NoProfile", + "-eNcOdEdCoMmAnD", + "cABhAHIAYQBtAA==", + ], + ), + dict( + id="powershell encoded command keeps surrounding flags", + input=dict( + cmd="""powershell -NoProfile -EncodedCommand 'cABhAHIAYQBtAA==' -InputFormat None""", + shell="powershell", + ), + output=[ + "powershell", + "-NoProfile", + "-EncodedCommand", + "cABhAHIAYQBtAA==", + "-InputFormat", + "None", + ], + ), + dict( + id="powershell -enc alias strips quotes", + input=dict( + cmd="""powershell -NoProfile -enc 'cABhAHIAYQBtAA=='""", + shell="powershell", + ), + output=[ + "powershell", + "-NoProfile", + "-enc", + "cABhAHIAYQBtAA==", + ], + ), + dict( + id="powershell -Command with spaces in path", + input=dict( + cmd="""powershell.exe -NonInteractive -Command 'Write-Host "hello"; & \\'C:\\My Scripts\\run me.ps1\\''""", + shell="powershell", + ), + output=[ + "powershell.exe", + "-NonInteractive", + "-Command", + """Write-Host "hello"; & \\'C:\\My Scripts\\run me.ps1\\'""", + ], + ), + dict( + id="powershell -File with spaces in path", + input=dict( + cmd='''powershell.exe -NonInteractive -File "C:\\My Scripts\\run me.ps1"''', + shell="powershell", + ), + output=[ + "powershell.exe", + "-NonInteractive", + "-File", + r"C:\My Scripts\run me.ps1", + ], + ), + dict( + id="powershell -File single quoted path with trailing args", + input=dict( + cmd="""powershell.exe -NoProfile -File 'C:\\My Scripts\\run me.ps1' -Arg1 value""", + shell="powershell", + ), + output=[ + "powershell.exe", + "-NoProfile", + "-File", + r"C:\My Scripts\run me.ps1", + "-Arg1", + "value", + ], + ), + dict( + id="powershell -F alias with spaces in path", + input=dict( + cmd='''powershell.exe -NoProfile -F "C:\\My Scripts\\run me.ps1"''', + shell="powershell", + ), + output=[ + "powershell.exe", + "-NoProfile", + "-F", + r"C:\My Scripts\run me.ps1", + ], + ), + dict( + id="powershell -Command outer double quotes", + input=dict( + cmd='''powershell.exe -NoProfile -Command "Write-Host 'hello world'"''', + shell="powershell", + ), + output=[ + "powershell.exe", + "-NoProfile", + "-Command", + "Write-Host 'hello world'", + ], + ), + dict( + id="powershell -Command empty quoted payload", + input=dict( + cmd='''powershell.exe -NoProfile -Command ""''', + shell="powershell", + ), + output=[ + "powershell.exe", + "-NoProfile", + "-Command", + "", + ], + ), ]