Don't mutate os.environ in AnsibleModule.run_command, make a copy, and pass to Popen (#74791)

* Don't mutate os.environ in AnsibleModule.run_command, make a copy, and pass to Popen. Fixes #74783

* Simplify code a bit

* More simple

* Address some other potentially non threadsafe operations

* Add if around umask

* Address unit test assumptions

* Add clog frag

* yaml syntax issue
This commit is contained in:
Matt Martz 2021-05-25 11:00:47 -05:00 committed by GitHub
parent df5ce3e672
commit 98138584b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 30 additions and 57 deletions

View file

@ -0,0 +1,5 @@
bugfixes:
- >
``AnsibleModule.run_command`` - Address thread safety issues, concerning mutating the environment,
current working directory, and umask.
(https://github.com/ansible/ansible/issues/74783)

View file

@ -1863,7 +1863,7 @@ class AnsibleModule(object):
:kw prompt_regex: Regex string (not a compiled regex) which can be :kw prompt_regex: Regex string (not a compiled regex) which can be
used to detect prompts in the stdout which would otherwise cause used to detect prompts in the stdout which would otherwise cause
the execution to hang (especially if no input data is specified) the execution to hang (especially if no input data is specified)
:kw environ_update: dictionary to *update* os.environ with :kw environ_update: dictionary to *update* environ variables with
:kw umask: Umask to be used when running the command. Default None :kw umask: Umask to be used when running the command. Default None
:kw encoding: Since we return native strings, on python3 we need to :kw encoding: Since we return native strings, on python3 we need to
know the encoding to use to transform from bytes to text. If you know the encoding to use to transform from bytes to text. If you
@ -1958,23 +1958,16 @@ class AnsibleModule(object):
msg = None msg = None
st_in = None st_in = None
# Manipulate the environ we'll send to the new process env = os.environ.copy()
old_env_vals = {}
# We can set this from both an attribute and per call # We can set this from both an attribute and per call
for key, val in self.run_command_environ_update.items(): env.update(self.run_command_environ_update or {})
old_env_vals[key] = os.environ.get(key, None) env.update(environ_update or {})
os.environ[key] = val
if environ_update:
for key, val in environ_update.items():
old_env_vals[key] = os.environ.get(key, None)
os.environ[key] = val
if path_prefix: if path_prefix:
path = os.environ.get('PATH', '') path = env.get('PATH', '')
old_env_vals['PATH'] = path
if path: if path:
os.environ['PATH'] = "%s:%s" % (path_prefix, path) env['PATH'] = "%s:%s" % (path_prefix, path)
else: else:
os.environ['PATH'] = path_prefix env['PATH'] = path_prefix
# If using test-module.py and explode, the remote lib path will resemble: # If using test-module.py and explode, the remote lib path will resemble:
# /tmp/test_module_scratch/debug_dir/ansible/module_utils/basic.py # /tmp/test_module_scratch/debug_dir/ansible/module_utils/basic.py
@ -1982,18 +1975,22 @@ class AnsibleModule(object):
# /tmp/ansible_vmweLQ/ansible_modlib.zip/ansible/module_utils/basic.py # /tmp/ansible_vmweLQ/ansible_modlib.zip/ansible/module_utils/basic.py
# Clean out python paths set by ansiballz # Clean out python paths set by ansiballz
if 'PYTHONPATH' in os.environ: if 'PYTHONPATH' in env:
pypaths = os.environ['PYTHONPATH'].split(':') pypaths = [x for x in env['PYTHONPATH'].split(':')
pypaths = [x for x in pypaths if x and
if not x.endswith('/ansible_modlib.zip') and not x.endswith('/ansible_modlib.zip') and
not x.endswith('/debug_dir')] not x.endswith('/debug_dir')]
os.environ['PYTHONPATH'] = ':'.join(pypaths) if pypaths and any(pypaths):
if not os.environ['PYTHONPATH']: env['PYTHONPATH'] = ':'.join(pypaths)
del os.environ['PYTHONPATH']
if data: if data:
st_in = subprocess.PIPE st_in = subprocess.PIPE
def preexec():
self._restore_signal_handlers()
if umask:
os.umask(umask)
kwargs = dict( kwargs = dict(
executable=executable, executable=executable,
shell=shell, shell=shell,
@ -2001,33 +1998,22 @@ class AnsibleModule(object):
stdin=st_in, stdin=st_in,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
preexec_fn=self._restore_signal_handlers, preexec_fn=preexec,
env=env,
) )
if PY3 and pass_fds: if PY3 and pass_fds:
kwargs["pass_fds"] = pass_fds kwargs["pass_fds"] = pass_fds
elif PY2 and pass_fds: elif PY2 and pass_fds:
kwargs['close_fds'] = False kwargs['close_fds'] = False
# store the pwd
prev_dir = os.getcwd()
# make sure we're in the right working directory # make sure we're in the right working directory
if cwd: if cwd:
cwd = to_bytes(os.path.abspath(os.path.expanduser(cwd)), errors='surrogate_or_strict')
if os.path.isdir(cwd): if os.path.isdir(cwd):
cwd = to_bytes(os.path.abspath(os.path.expanduser(cwd)), errors='surrogate_or_strict')
kwargs['cwd'] = cwd kwargs['cwd'] = cwd
try:
os.chdir(cwd)
except (OSError, IOError) as e:
self.fail_json(rc=e.errno, msg="Could not chdir to %s, %s" % (cwd, to_native(e)),
exception=traceback.format_exc())
elif not ignore_invalid_cwd: elif not ignore_invalid_cwd:
self.fail_json(msg="Provided cwd is not a valid directory: %s" % cwd) self.fail_json(msg="Provided cwd is not a valid directory: %s" % cwd)
old_umask = None
if umask:
old_umask = os.umask(umask)
try: try:
if self._debug: if self._debug:
self.log('Executing: ' + self._clean_args(args)) self.log('Executing: ' + self._clean_args(args))
@ -2103,23 +2089,10 @@ class AnsibleModule(object):
self.log("Error Executing CMD:%s Exception:%s" % (self._clean_args(args), to_native(traceback.format_exc()))) self.log("Error Executing CMD:%s Exception:%s" % (self._clean_args(args), to_native(traceback.format_exc())))
self.fail_json(rc=257, stdout=b'', stderr=b'', msg=to_native(e), exception=traceback.format_exc(), cmd=self._clean_args(args)) self.fail_json(rc=257, stdout=b'', stderr=b'', msg=to_native(e), exception=traceback.format_exc(), cmd=self._clean_args(args))
# Restore env settings
for key, val in old_env_vals.items():
if val is None:
del os.environ[key]
else:
os.environ[key] = val
if old_umask:
os.umask(old_umask)
if rc != 0 and check_rc: if rc != 0 and check_rc:
msg = heuristic_log_sanitize(stderr.rstrip(), self.no_log_values) msg = heuristic_log_sanitize(stderr.rstrip(), self.no_log_values)
self.fail_json(cmd=self._clean_args(args), rc=rc, stdout=stdout, stderr=stderr, msg=msg) self.fail_json(cmd=self._clean_args(args), rc=rc, stdout=stdout, stderr=stderr, msg=msg)
# reset the pwd
os.chdir(prev_dir)
if encoding is not None: if encoding is not None:
return (rc, to_native(stdout, encoding=encoding, errors=errors), return (rc, to_native(stdout, encoding=encoding, errors=errors),
to_native(stderr, encoding=encoding, errors=errors)) to_native(stderr, encoding=encoding, errors=errors))

View file

@ -163,27 +163,22 @@ class TestRunCommandArgs:
class TestRunCommandCwd: class TestRunCommandCwd:
@pytest.mark.parametrize('stdin', [{}], indirect=['stdin']) @pytest.mark.parametrize('stdin', [{}], indirect=['stdin'])
def test_cwd(self, mocker, rc_am): def test_cwd(self, mocker, rc_am):
rc_am._os.getcwd.return_value = '/old'
rc_am.run_command('/bin/ls', cwd='/new') rc_am.run_command('/bin/ls', cwd='/new')
assert rc_am._os.chdir.mock_calls == [mocker.call(b'/new'), mocker.call('/old'), ] assert rc_am._subprocess.Popen.mock_calls[0][2]['cwd'] == b'/new'
@pytest.mark.parametrize('stdin', [{}], indirect=['stdin']) @pytest.mark.parametrize('stdin', [{}], indirect=['stdin'])
def test_cwd_relative_path(self, mocker, rc_am): def test_cwd_relative_path(self, mocker, rc_am):
rc_am._os.getcwd.return_value = '/old'
rc_am.run_command('/bin/ls', cwd='sub-dir') rc_am.run_command('/bin/ls', cwd='sub-dir')
assert rc_am._os.chdir.mock_calls == [mocker.call(b'/old/sub-dir'), mocker.call('/old'), ] assert rc_am._subprocess.Popen.mock_calls[0][2]['cwd'] == b'/home/foo/sub-dir'
@pytest.mark.parametrize('stdin', [{}], indirect=['stdin']) @pytest.mark.parametrize('stdin', [{}], indirect=['stdin'])
def test_cwd_not_a_dir(self, mocker, rc_am): def test_cwd_not_a_dir(self, mocker, rc_am):
rc_am._os.getcwd.return_value = '/old'
rc_am._os.path.isdir.side_effect = lambda d: d != '/not-a-dir'
rc_am.run_command('/bin/ls', cwd='/not-a-dir') rc_am.run_command('/bin/ls', cwd='/not-a-dir')
assert rc_am._os.chdir.mock_calls == [mocker.call('/old'), ] assert rc_am._subprocess.Popen.mock_calls[0][2]['cwd'] == b'/not-a-dir'
@pytest.mark.parametrize('stdin', [{}], indirect=['stdin']) @pytest.mark.parametrize('stdin', [{}], indirect=['stdin'])
def test_cwd_not_a_dir_noignore(self, rc_am): def test_cwd_not_a_dir_noignore(self, rc_am):
rc_am._os.getcwd.return_value = '/old' rc_am._os.path.isdir.side_effect = lambda d: d != b'/not-a-dir'
rc_am._os.path.isdir.side_effect = lambda d: d != '/not-a-dir'
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
rc_am.run_command('/bin/ls', cwd='/not-a-dir', ignore_invalid_cwd=False) rc_am.run_command('/bin/ls', cwd='/not-a-dir', ignore_invalid_cwd=False)
assert rc_am.fail_json.called assert rc_am.fail_json.called