[PR #11771/df252e5f backport][stable-12] incus, machinectl, run0 - fix become over pty connections (#11827)

incus, machinectl, run0 - fix become over pty connections (#11771)

* incus, machinectl, run0 - fix become over pty connections

Four small fixes across three plugins, all discovered while trying to
use community.general.machinectl (and later community.general.run0)
as become methods over the community.general.incus connection.

Core bug: machinectl and run0 both set require_tty = True, but the
incus connection plugin was ignoring that hint and invoking
'incus exec' without -t. Honor require_tty by passing -t, mirroring
what the OpenSSH plugin does with -tt.

Once the pty is in place, both become plugins emit terminal control
sequences (window-title OSC, ANSI reset) around the child command
that land in captured stdout alongside the module JSON and trip the
result parser with "Module invocation had junk after the JSON data".
Suppress that decoration at the source by prefixing the constructed
shell command with SYSTEMD_COLORS=0. TERM=dumb would work too but
has a wider blast radius (it also affects interactive tools inside
the become-user session); SYSTEMD_COLORS is the documented
systemd-scoped knob.

run0 was also missing pipelining = False. When run0 is used over a
connection that honors require_tty, ansible's pipelining sends the
module source on stdin to remote python3, which cannot be forwarded
cleanly through the pty chain and hangs indefinitely. Disable
pipelining the same way community.general.machinectl already does.

Also add tests/unit/plugins/become/test_machinectl.py mirroring the
existing test_run0.py. machinectl had no unit test coverage before,
which is why CI did not catch the SYSTEMD_COLORS=0 prefix change
when the equivalent run0 change broke test_run0_basic/test_run0_flags.



* Update changelogs/fragments/11771-incus-machinectl-run0-become-pty.yml



---------



(cherry picked from commit df252e5fab)

Co-authored-by: Martin Schürrer <martin@schuerrer.org>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>
This commit is contained in:
patchback[bot]
2026-04-15 22:01:06 +02:00
committed by GitHub
parent 86dc3c8816
commit 705ffc564d
6 changed files with 92 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
bugfixes:
- incus connection plugin - work when the active become plugin sets ``require_tty`` instead of failing silently (https://github.com/ansible-collections/community.general/pull/11771).
- machinectl become plugin - prevent printing ANSI terminal color sequences (https://github.com/ansible-collections/community.general/pull/11771).
- run0 become plugin - prevent printing ANSI terminal color sequences (https://github.com/ansible-collections/community.general/pull/11771).
- run0 become plugin - mark the plugin as incompatible with connection pipelining (see https://github.com/ansible/ansible/issues/81254, https://github.com/ansible-collections/community.general/pull/11771).

View File

@@ -124,7 +124,10 @@ class BecomeModule(BecomeBase):
flags = self.get_option("become_flags")
user = self.get_option("become_user")
return f"{become} -q shell {flags} {user}@ {self._build_success_command(cmd, shell)}"
# SYSTEMD_COLORS=0 stops machinectl from appending ANSI reset
# sequences (ESC[0m, ESC[J) after the child exits, which would
# otherwise land after the module JSON and break result parsing.
return f"SYSTEMD_COLORS=0 {become} -q shell {flags} {user}@ {self._build_success_command(cmd, shell)}"
def check_success(self, b_output):
b_output = self.remove_ansi_codes(b_output)

View File

@@ -60,6 +60,8 @@ options:
type: string
notes:
- This plugin only works when a C(polkit) rule is in place.
- This become plugin does not work when connection pipelining is enabled. With ansible-core 2.19+, using it automatically
disables pipelining. On ansible-core 2.18 and before, pipelining must explicitly be disabled by the user.
"""
EXAMPLES = r"""
@@ -91,6 +93,10 @@ class BecomeModule(BecomeBase):
success = ("==== AUTHENTICATION COMPLETE ====",)
require_tty = True # see https://github.com/ansible-collections/community.general/issues/6932
# See https://github.com/ansible/ansible/issues/81254,
# https://github.com/ansible/ansible/pull/78111
pipelining = False
@staticmethod
def remove_ansi_codes(line):
return ansi_color_codes.sub(b"", line)
@@ -105,7 +111,11 @@ class BecomeModule(BecomeBase):
flags = self.get_option("become_flags")
user = self.get_option("become_user")
return f"{become} --user={user} {flags} {self._build_success_command(cmd, shell)}"
# SYSTEMD_COLORS=0 stops run0 from emitting terminal control
# sequences (window title OSC, ANSI reset) around the child
# command, which would otherwise corrupt the module JSON and
# break result parsing.
return f"SYSTEMD_COLORS=0 {become} --user={user} {flags} {self._build_success_command(cmd, shell)}"
def check_success(self, b_output):
b_output = self.remove_ansi_codes(b_output)

View File

@@ -131,12 +131,18 @@ class Connection(ConnectionBase):
def _build_command(self, cmd) -> list[str]:
"""build the command to execute on the incus host"""
# Force pseudo-terminal allocation if the active become plugin
# requires one (e.g. community.general.machinectl), otherwise the
# become helper runs without a controlling tty and silently fails.
require_tty = self.become is not None and getattr(self.become, "require_tty", False)
exec_cmd: list[str] = [
self._incus_cmd,
"--project",
self.get_option("project"),
"exec",
*(["-T"] if getattr(self._shell, "_IS_WINDOWS", False) else []),
*(["-t"] if require_tty and not getattr(self._shell, "_IS_WINDOWS", False) else []),
f"{self.get_option('remote')}:{self._instance()}",
"--",
]

View File

@@ -0,0 +1,64 @@
# Copyright (c) 2026 Ansible Project
#
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
import re
from ansible import context
from .helper import call_become_plugin
def test_machinectl_basic(mocker, parser, reset_cli_args):
options = parser.parse_args([])
context._init_global_context(options)
default_cmd = "/bin/foo"
default_exe = "/bin/sh"
machinectl_exe = "machinectl"
success = "BECOME-SUCCESS-.+?"
task = {
"become_method": "community.general.machinectl",
"become_user": "root",
}
var_options = {}
cmd = call_become_plugin(task, var_options, cmd=default_cmd, executable=default_exe)
assert (
re.match(
f"SYSTEMD_COLORS=0 {machinectl_exe} -q shell root@ {default_exe} -c 'echo {success}; {default_cmd}'",
cmd,
)
is not None
)
def test_machinectl_flags(mocker, parser, reset_cli_args):
options = parser.parse_args([])
context._init_global_context(options)
default_cmd = "/bin/foo"
default_exe = "/bin/sh"
machinectl_exe = "machinectl"
machinectl_flags = "--setenv=FOO=bar"
success = "BECOME-SUCCESS-.+?"
task = {
"become_method": "community.general.machinectl",
"become_user": "root",
"become_flags": machinectl_flags,
}
var_options = {}
cmd = call_become_plugin(task, var_options, cmd=default_cmd, executable=default_exe)
assert (
re.match(
f"SYSTEMD_COLORS=0 {machinectl_exe} -q shell --setenv=FOO=bar root@ {default_exe} -c 'echo {success}; {default_cmd}'",
cmd,
)
is not None
)

View File

@@ -29,7 +29,7 @@ def test_run0_basic(mocker, parser, reset_cli_args):
cmd = call_become_plugin(task, var_options, cmd=default_cmd, executable=default_exe)
assert (
re.match(
f"{run0_exe} --user=root {default_exe} -c 'echo {success}; {default_cmd}'",
f"SYSTEMD_COLORS=0 {run0_exe} --user=root {default_exe} -c 'echo {success}; {default_cmd}'",
cmd,
)
is not None
@@ -55,7 +55,7 @@ def test_run0_flags(mocker, parser, reset_cli_args):
cmd = call_become_plugin(task, var_options, cmd=default_cmd, executable=default_exe)
assert (
re.match(
f"{run0_exe} --user=root --nice=15 {default_exe} -c 'echo {success}; {default_cmd}'",
f"SYSTEMD_COLORS=0 {run0_exe} --user=root --nice=15 {default_exe} -c 'echo {success}; {default_cmd}'",
cmd,
)
is not None