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:
parent
df5ce3e672
commit
98138584b7
3 changed files with 30 additions and 57 deletions
5
changelogs/fragments/74783-run-command-thread-safety.yml
Normal file
5
changelogs/fragments/74783-run-command-thread-safety.yml
Normal 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)
|
|
@ -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:
|
||||||
if os.path.isdir(cwd):
|
|
||||||
cwd = to_bytes(os.path.abspath(os.path.expanduser(cwd)), errors='surrogate_or_strict')
|
cwd = to_bytes(os.path.abspath(os.path.expanduser(cwd)), errors='surrogate_or_strict')
|
||||||
|
if os.path.isdir(cwd):
|
||||||
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))
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue