Skip to content

Commit 27b75ed

Browse files
authored
Fix container --tty detection in subprocess mode (#1306)
Check input and output file descriptors for tty for run_command() API in subprocess path. The pexpect path is left unchanged. --- Signed-off-by: xz-dev <xiangzhedev@gmail.com>
1 parent 9e7da04 commit 27b75ed

File tree

4 files changed

+137
-2
lines changed

4 files changed

+137
-2
lines changed

src/ansible_runner/config/_base.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,15 @@ def _handle_ansible_cmd_options_bind_mounts(self, args_list: list[str], cmdline_
487487

488488
self._update_volume_mount_paths(args_list, optional_arg_value)
489489

490+
def should_allocate_tty(self) -> bool:
491+
"""Whether the container should get a ``--tty`` flag.
492+
493+
The base implementation returns ``False``.
494+
Subclasses (e.g. :class:`~ansible_runner.config.command.CommandConfig`)
495+
may override this to implement richer logic.
496+
"""
497+
return False
498+
490499
def wrap_args_for_containerization(self,
491500
args: list[str],
492501
execution_mode: BaseExecutionMode,
@@ -495,7 +504,7 @@ def wrap_args_for_containerization(self,
495504
new_args = [self.process_isolation_executable]
496505
new_args.extend(['run', '--rm'])
497506

498-
if self.runner_mode == 'pexpect' or getattr(self, 'input_fd', False):
507+
if self.runner_mode == 'pexpect' or self.should_allocate_tty():
499508
new_args.extend(['--tty'])
500509

501510
new_args.append('--interactive')

src/ansible_runner/config/command.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,20 @@ def __init__(self, input_fd=None, output_fd=None, error_fd=None, runner_mode=Non
6969
'ansible-galaxy',
7070
)
7171

72+
def should_allocate_tty(self) -> bool:
73+
"""Allocate a TTY only when *both* input and output fds are real terminals.
74+
75+
If either side is redirected (file, pipe, ``/dev/null``) the container
76+
must not get ``--tty``, otherwise tools like ``less`` hang or ANSI
77+
escapes pollute the redirected output.
78+
79+
See: https://github.com/ansible/ansible-navigator/issues/1607
80+
"""
81+
return (
82+
self.input_fd is not None and hasattr(self.input_fd, 'isatty') and self.input_fd.isatty()
83+
and self.output_fd is not None and hasattr(self.output_fd, 'isatty') and self.output_fd.isatty()
84+
)
85+
7286
def _set_runner_mode(self):
7387
if self.input_fd is not None or self.executable_cmd.split(os.pathsep)[-1] in CommandConfig._ANSIBLE_NON_INERACTIVE_CMDS:
7488
self.runner_mode = 'subprocess'

test/integration/containerized/test_container_management.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import pty
23
import time
34
import json
45

@@ -7,7 +8,7 @@
78

89
import pytest
910

10-
from ansible_runner.interface import run
11+
from ansible_runner.interface import run, run_command
1112

1213

1314
@pytest.mark.test_all_runtimes
@@ -182,3 +183,72 @@ def test_registry_auth_file_cleanup(tmp_path, cli, runtime):
182183
discovered_registry_files = set(glob(auth_registry_glob)) - registry_files_before
183184
for file_name in discovered_registry_files:
184185
assert this_ident not in file_name
186+
187+
188+
@pytest.mark.test_all_runtimes
189+
@pytest.mark.parametrize(
190+
('stdin_is_tty', 'stdout_is_tty'),
191+
(
192+
pytest.param(False, False, id='piped-stdin'),
193+
pytest.param(True, False, id='stdout-redirected'),
194+
pytest.param(None, None, id='no-fd-headless'),
195+
),
196+
)
197+
def test_containerized_tty_allocation(
198+
tmp_path, runtime, container_image, stdin_is_tty, stdout_is_tty,
199+
):
200+
"""Regression for ansible-navigator#1607: --tty must not appear when fds are not real TTYs.
201+
202+
The positive case (both fds are TTYs -> --tty is added) is already
203+
covered by the unit test
204+
``test_prepare_run_command_containerized_tty_allocation[interactive-tty-*]``
205+
which asserts ``'--tty' in rc.command``. Testing it at the integration
206+
level would require a pager (``less``) inside the container image and
207+
complex pty plumbing, so we only verify the "no --tty" cases here.
208+
"""
209+
fds_to_close = []
210+
kwargs = {
211+
'executable_cmd': 'ansible-config',
212+
'cmdline_args': ['init'],
213+
'private_data_dir': str(tmp_path),
214+
'process_isolation': True,
215+
'process_isolation_executable': runtime,
216+
'container_image': container_image,
217+
}
218+
219+
if stdin_is_tty is not None:
220+
if stdin_is_tty:
221+
master, slave = pty.openpty()
222+
kwargs['input_fd'] = os.fdopen(slave, 'r')
223+
fds_to_close.append(('fd', kwargs['input_fd']))
224+
fds_to_close.append(('raw', master))
225+
else:
226+
input_path = tmp_path / 'stdin.txt'
227+
input_path.write_text('')
228+
kwargs['input_fd'] = input_path.open('r', encoding='utf-8')
229+
fds_to_close.append(('fd', kwargs['input_fd']))
230+
231+
if stdout_is_tty is not None:
232+
kwargs['output_fd'] = (tmp_path / 'stdout.txt').open('w', encoding='utf-8')
233+
fds_to_close.append(('fd', kwargs['output_fd']))
234+
235+
if stdin_is_tty is not None or stdout_is_tty is not None:
236+
kwargs['error_fd'] = (tmp_path / 'stderr.txt').open('w', encoding='utf-8')
237+
fds_to_close.append(('fd', kwargs['error_fd']))
238+
239+
try:
240+
response, _, rc = run_command(**kwargs)
241+
finally:
242+
for kind, fd in fds_to_close:
243+
if kind == 'fd':
244+
fd.close()
245+
else:
246+
os.close(fd)
247+
248+
if stdin_is_tty is None:
249+
content = response
250+
else:
251+
content = (tmp_path / 'stdout.txt').read_text(encoding='utf-8')
252+
assert rc == 0
253+
assert '[defaults]' in content
254+
assert '\x1b' not in content

test/unit/config/test_command.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,45 @@ def test_prepare_run_command_with_containerization(tmp_path, runtime, mocker):
124124
expected_command_start.extend(cmdline_args)
125125

126126
assert expected_command_start == rc.command
127+
128+
129+
@pytest.mark.parametrize('runtime', ('docker', 'podman'))
130+
@pytest.mark.parametrize(
131+
('stdin_is_tty', 'stdout_is_tty', 'expect_tty'),
132+
(
133+
pytest.param(False, False, False, id='piped-stdin'),
134+
pytest.param(True, True, True, id='interactive-tty'),
135+
pytest.param(True, False, False, id='stdout-redirected'),
136+
pytest.param(None, None, False, id='no-fd-headless'),
137+
),
138+
)
139+
def test_prepare_run_command_containerized_tty_allocation(
140+
tmp_path, runtime, mocker, stdin_is_tty, stdout_is_tty, expect_tty,
141+
):
142+
"""Regression for ansible-navigator#1607: --tty must only appear when both fds are real TTYs."""
143+
mocker.patch.dict('os.environ', {'HOME': str(tmp_path)}, clear=True)
144+
tmp_path.joinpath('.ssh').mkdir()
145+
146+
kwargs = {
147+
'private_data_dir': tmp_path,
148+
'process_isolation': True,
149+
'container_image': 'my_container',
150+
'process_isolation_executable': runtime,
151+
}
152+
153+
if stdin_is_tty is not None:
154+
mock_stdin = mocker.Mock()
155+
mock_stdin.isatty.return_value = stdin_is_tty
156+
kwargs['input_fd'] = mock_stdin
157+
if stdout_is_tty is not None:
158+
mock_stdout = mocker.Mock()
159+
mock_stdout.isatty.return_value = stdout_is_tty
160+
kwargs['output_fd'] = mock_stdout
161+
162+
rc = CommandConfig(**kwargs)
163+
rc.ident = 'foo'
164+
rc.prepare_run_command('ansible-config', cmdline_args=['init'])
165+
166+
assert rc.runner_mode == 'subprocess'
167+
assert ('--tty' in rc.command) == expect_tty
168+
assert '--interactive' in rc.command

0 commit comments

Comments
 (0)