Skip to content

Commit dec5f23

Browse files
authored
Merge pull request #3867 from StackStorm/local_remote_runners_sudo_password_support
Add support for sudo password to the local and remote runners
2 parents 6aeca26 + f136be4 commit dec5f23

File tree

24 files changed

+533
-62
lines changed

24 files changed

+533
-62
lines changed

CHANGELOG.rst

+14-9
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ Added
3535
common code inside a ``lib`` directory inside a pack (with an ``__init__.py`` inside ``lib``
3636
directory to declare it a python package). You can then import the common code in sensors and
3737
actions. Please refer to documentation for samples and guidelines. #3490
38+
* Add support for password protected sudo to the local and remote runner. Password can be provided
39+
via the new ``sudo_password`` runner parameter. (new feature) #3867
40+
* Add new ``--tail`` flag to the ``st2 run`` / ``st2 action execute`` and ``st2 execution re-run``
41+
CLI command. When this flag is provided, new execution will automatically be followed and tailed
42+
after it has been scheduled. (new feature) #3867
3843

3944
Changed
4045
~~~~~~~
@@ -61,16 +66,16 @@ Changed
6166
``AUDIT``. #3845
6267
* Mask values in an Inquiry response displayed to the user that were marked as "secret" in the
6368
inquiry's response schema. #3825
64-
* Added the ability of ``st2 key load`` to load keys from both JSON and YAML files.
65-
Files can now contain a single KeyValuePair, or an array of KeyValuePairs.
66-
Added the ability of ``st2 key load`` to load non-string values (hashes, arrays,
67-
integers, booleans) and convert them to JSON before going into the datastore,
68-
this conversion requires the user passing in the ``-c/--convert`` flag.
69-
Updated ``st2 key load`` to load all properties of a key/value pair, now
70-
secret values can be loaded. (improvement) #3815
69+
* Add the ability of ``st2 key load`` to load keys from both JSON and YAML files. Files can now
70+
contain a single KeyValuePair, or an array of KeyValuePairs. (improvement) #3815
71+
* Add the ability of ``st2 key load`` to load non-string values (objects, arrays, integers,
72+
booleans) and convert them to JSON before going into the datastore, this conversion requires the
73+
user passing in the ``-c/--convert`` flag. (improvement) #3815
74+
* Update ``st2 key load`` to load all properties of a key/value pair, now secret values can be
75+
loaded. (improvement) #3815
7176

7277
Contributed by Nick Maludy (Encore Technologies).
73-
78+
7479
Fixed
7580
~~~~~
7681

@@ -215,7 +220,7 @@ Fixed
215220
Changed
216221
~~~~~~~
217222

218-
* Minor language and style tidy up of help strings and error messages #3782
223+
* Minor language and style tidy up of help strings and error messages. #3782
219224

220225
2.4.1 - September 12, 2017
221226
--------------------------

contrib/runners/local_runner/local_runner/local_runner.py

+37-4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# limitations under the License.
1515

1616
import os
17+
import re
1718
import pwd
1819
import uuid
1920
import functools
@@ -52,6 +53,7 @@
5253

5354
# constants to lookup in runner_parameters.
5455
RUNNER_SUDO = 'sudo'
56+
RUNNER_SUDO_PASSWORD = 'sudo_password'
5557
RUNNER_ON_BEHALF_USER = 'user'
5658
RUNNER_COMMAND = 'cmd'
5759
RUNNER_CWD = 'cwd'
@@ -84,6 +86,7 @@ def pre_run(self):
8486
super(LocalShellRunner, self).pre_run()
8587

8688
self._sudo = self.runner_parameters.get(RUNNER_SUDO, False)
89+
self._sudo_password = self.runner_parameters.get(RUNNER_SUDO_PASSWORD, None)
8790
self._on_behalf_user = self.context.get(RUNNER_ON_BEHALF_USER, LOGGED_USER_USERNAME)
8891
self._user = cfg.CONF.system_user.user
8992
self._cwd = self.runner_parameters.get(RUNNER_CWD, None)
@@ -105,7 +108,8 @@ def run(self, action_parameters):
105108
user=self._user,
106109
env_vars=env_vars,
107110
sudo=self._sudo,
108-
timeout=self._timeout)
111+
timeout=self._timeout,
112+
sudo_password=self._sudo_password)
109113
else:
110114
script_action = True
111115
script_local_path_abs = self.entry_point
@@ -121,13 +125,16 @@ def run(self, action_parameters):
121125
env_vars=env_vars,
122126
sudo=self._sudo,
123127
timeout=self._timeout,
124-
cwd=self._cwd)
128+
cwd=self._cwd,
129+
sudo_password=self._sudo_password)
125130

126131
args = action.get_full_command_string()
132+
sanitized_args = action.get_sanitized_full_command_string()
127133

128134
# For consistency with the old Fabric based runner, make sure the file is executable
129135
if script_action:
130136
args = 'chmod +x %s ; %s' % (script_local_path_abs, args)
137+
sanitized_args = 'chmod +x %s ; %s' % (script_local_path_abs, sanitized_args)
131138

132139
env = os.environ.copy()
133140

@@ -140,7 +147,7 @@ def run(self, action_parameters):
140147

141148
LOG.info('Executing action via LocalRunner: %s', self.runner_id)
142149
LOG.info('[Action info] name: %s, Id: %s, command: %s, user: %s, sudo: %s' %
143-
(action.name, action.action_exec_id, args, action.user, action.sudo))
150+
(action.name, action.action_exec_id, sanitized_args, action.user, action.sudo))
144151

145152
stdout = StringIO()
146153
stderr = StringIO()
@@ -155,6 +162,17 @@ def run(self, action_parameters):
155162
read_and_store_stderr = make_read_and_store_stream_func(execution_db=self.execution,
156163
action_db=self.action, store_data_func=store_execution_stderr_line)
157164

165+
# If sudo password is provided, pass it to the subprocess via stdin>
166+
# Note: We don't need to explicitly escape the argument because we pass command as a list
167+
# to subprocess.Popen and all the arguments are escaped by the function.
168+
if self._sudo_password:
169+
LOG.debug('Supplying sudo password via stdin')
170+
echo_process = subprocess.Popen(['echo', self._sudo_password + '\n'],
171+
stdout=subprocess.PIPE)
172+
stdin = echo_process.stdout
173+
else:
174+
stdin = None
175+
158176
# Make sure os.setsid is called on each spawned process so that all processes
159177
# are in the same group.
160178

@@ -164,7 +182,8 @@ def run(self, action_parameters):
164182
# Ideally os.killpg should have done the trick but for some reason that failed.
165183
# Note: pkill will set the returncode to 143 so we don't need to explicitly set
166184
# it to some non-zero value.
167-
exit_code, stdout, stderr, timed_out = shell.run_command(cmd=args, stdin=None,
185+
exit_code, stdout, stderr, timed_out = shell.run_command(cmd=args,
186+
stdin=stdin,
168187
stdout=subprocess.PIPE,
169188
stderr=subprocess.PIPE,
170189
shell=True,
@@ -184,6 +203,20 @@ def run(self, action_parameters):
184203
error = 'Action failed to complete in %s seconds' % (self._timeout)
185204
exit_code = -1 * exit_code_constants.SIGKILL_EXIT_CODE
186205

206+
# Detect if user provided an invalid sudo password or sudo is not configured for that user
207+
if self._sudo_password:
208+
if re.search('sudo: \d+ incorrect password attempts', stderr):
209+
match = re.search('\[sudo\] password for (.+?)\:', stderr)
210+
211+
if match:
212+
username = match.groups()[0]
213+
else:
214+
username = 'unknown'
215+
216+
error = ('Invalid sudo password provided or sudo is not configured for this user '
217+
'(%s)' % (username))
218+
exit_code = -1
219+
187220
succeeded = (exit_code == exit_code_constants.SUCCESS_EXIT_CODE)
188221

189222
result = {

contrib/runners/local_runner/local_runner/runner.yaml

+12
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@
2323
default: false
2424
description: The command will be executed with sudo.
2525
type: boolean
26+
sudo_password:
27+
default: null
28+
description: Sudo password. To be used when paswordless sudo is not allowed.
29+
type: string
30+
secret: true
31+
required: false
2632
timeout:
2733
default: 60
2834
description: Action timeout in seconds. Action will get killed if it doesn't
@@ -50,6 +56,12 @@
5056
default: false
5157
description: The command will be executed with sudo.
5258
type: boolean
59+
sudo_password:
60+
default: null
61+
description: Sudo password. To be used when paswordless sudo is not allowed.
62+
type: string
63+
required: false
64+
secret: true
5365
timeout:
5466
default: 60
5567
description: Action timeout in seconds. Action will get killed if it doesn't

contrib/runners/local_runner/tests/integration/test_localrunner.py

+82
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,88 @@ def test_action_stdout_and_stderr_is_stored_in_the_db_short_running_action(self,
314314
self.assertEqual(output_dbs[db_index_1].data, mock_stderr[0])
315315
self.assertEqual(output_dbs[db_index_2].data, mock_stderr[1])
316316

317+
def test_shell_command_sudo_password_is_passed_to_sudo_binary(self):
318+
# Verify that sudo password is correctly passed to sudo binary via stdin
319+
models = self.fixtures_loader.load_models(
320+
fixtures_pack='generic', fixtures_dict={'actions': ['local.yaml']})
321+
action_db = models['actions']['local.yaml']
322+
323+
sudo_passwords = [
324+
'pass 1',
325+
'sudopass',
326+
'$sudo p@ss 2'
327+
]
328+
329+
cmd = ('{ read sudopass; echo $sudopass; }')
330+
331+
# without sudo
332+
for sudo_password in sudo_passwords:
333+
runner = self._get_runner(action_db, cmd=cmd)
334+
runner.pre_run()
335+
runner._sudo_password = sudo_password
336+
status, result, _ = runner.run({})
337+
runner.post_run(status, result)
338+
339+
self.assertEquals(status,
340+
action_constants.LIVEACTION_STATUS_SUCCEEDED)
341+
self.assertEquals(result['stdout'], sudo_password)
342+
343+
# with sudo
344+
for sudo_password in sudo_passwords:
345+
runner = self._get_runner(action_db, cmd=cmd)
346+
runner.pre_run()
347+
runner._sudo = True
348+
runner._sudo_password = sudo_password
349+
status, result, _ = runner.run({})
350+
runner.post_run(status, result)
351+
352+
self.assertEquals(status,
353+
action_constants.LIVEACTION_STATUS_SUCCEEDED)
354+
self.assertEquals(result['stdout'], sudo_password)
355+
356+
# Verify new process which provides password via stdin to the command is created
357+
with mock.patch('eventlet.green.subprocess.Popen') as mock_subproc_popen:
358+
index = 0
359+
for sudo_password in sudo_passwords:
360+
runner = self._get_runner(action_db, cmd=cmd)
361+
runner.pre_run()
362+
runner._sudo = True
363+
runner._sudo_password = sudo_password
364+
status, result, _ = runner.run({})
365+
runner.post_run(status, result)
366+
367+
if index == 0:
368+
call_args = mock_subproc_popen.call_args_list[index]
369+
else:
370+
call_args = mock_subproc_popen.call_args_list[index * 2]
371+
372+
index += 1
373+
374+
self.assertEqual(call_args[0][0], ['echo', '%s\n' % (sudo_password)])
375+
376+
self.assertEqual(index, len(sudo_passwords))
377+
378+
def test_shell_command_invalid_stdout_password(self):
379+
# Simulate message printed to stderr by sudo when invalid sudo password is provided
380+
models = self.fixtures_loader.load_models(
381+
fixtures_pack='generic', fixtures_dict={'actions': ['local.yaml']})
382+
action_db = models['actions']['local.yaml']
383+
384+
cmd = ('echo "[sudo] password for bar: Sorry, try again.\n[sudo] password for bar:'
385+
' Sorry, try again.\n[sudo] password for bar: \nsudo: 2 incorrect password '
386+
'attempts" 1>&2; exit 1')
387+
runner = self._get_runner(action_db, cmd=cmd)
388+
runner.pre_run()
389+
runner._sudo_password = 'pass'
390+
status, result, _ = runner.run({})
391+
runner.post_run(status, result)
392+
393+
expected_error = ('Invalid sudo password provided or sudo is not configured for this '
394+
'user (bar)')
395+
self.assertEquals(status, action_constants.LIVEACTION_STATUS_FAILED)
396+
self.assertEqual(result['error'], expected_error)
397+
self.assertEquals(result['stdout'], '')
398+
317399
@staticmethod
318400
def _get_runner(action_db,
319401
entry_point=None,

contrib/runners/remote_command_runner/remote_command_runner/remote_command_runner.py

+1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def _get_remote_action(self, action_paramaters):
7070
hosts=self._hosts,
7171
parallel=self._parallel,
7272
sudo=self._sudo,
73+
sudo_password=self._sudo_password,
7374
timeout=self._timeout,
7475
cwd=self._cwd)
7576

contrib/runners/remote_command_runner/remote_command_runner/runner.yaml

+6
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@
6868
default: false
6969
description: The remote command will be executed with sudo.
7070
type: boolean
71+
sudo_password:
72+
default: null
73+
description: Sudo password. To be used when paswordless sudo is not allowed.
74+
type: string
75+
required: false
76+
secret: true
7177
timeout:
7278
default: 60
7379
description: Action timeout in seconds. Action will get killed if it doesn't

contrib/runners/remote_script_runner/remote_script_runner/remote_script_runner.py

+1
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ def _get_remote_action(self, action_parameters):
144144
hosts=self._hosts,
145145
parallel=self._parallel,
146146
sudo=self._sudo,
147+
sudo_password=self._sudo_password,
147148
timeout=self._timeout,
148149
cwd=self._cwd)
149150

contrib/runners/remote_script_runner/remote_script_runner/runner.yaml

+6
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@
6464
default: false
6565
description: The remote command will be executed with sudo.
6666
type: boolean
67+
sudo_password:
68+
default: null
69+
description: Sudo password. To be used when paswordless sudo is not allowed.
70+
type: string
71+
required: false
72+
secret: true
6773
timeout:
6874
default: 60
6975
description: Action timeout in seconds. Action will get killed if it doesn't

lint-configs/python/.flake8

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[flake8]
22
max-line-length = 100
3-
ignore = E128,E402
3+
ignore = E128,E402,E722
44
exclude=*.egg/*

st2actions/tests/unit/test_parallel_ssh.py

+31
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@
2525
import st2tests.config as tests_config
2626
tests_config.parse_args()
2727

28+
MOCK_STDERR_SUDO_PASSWORD_ERROR = """
29+
[sudo] password for bar: Sorry, try again.\n
30+
[sudo] password for bar:' Sorry, try again.\n
31+
[sudo] password for bar: \n
32+
sudo: 2 incorrect password attempts
33+
"""
34+
2835

2936
class ParallelSSHTests(unittest2.TestCase):
3037

@@ -250,3 +257,27 @@ def test_run_command_json_output_transformed_to_object(self):
250257
results = client.run('stuff', timeout=60)
251258
self.assertTrue('127.0.0.1' in results)
252259
self.assertDictEqual(results['127.0.0.1']['stdout'], {'foo': 'bar'})
260+
261+
@patch('paramiko.SSHClient', Mock)
262+
@patch.object(ParamikoSSHClient, 'run', MagicMock(
263+
return_value=('', MOCK_STDERR_SUDO_PASSWORD_ERROR, 0))
264+
)
265+
@patch.object(ParamikoSSHClient, '_is_key_file_needs_passphrase',
266+
MagicMock(return_value=False))
267+
def test_run_sudo_password_user_friendly_error(self):
268+
hosts = ['127.0.0.1']
269+
client = ParallelSSHClient(hosts=hosts,
270+
user='ubuntu',
271+
pkey_file='~/.ssh/id_rsa',
272+
connect=True,
273+
sudo_password=True)
274+
results = client.run('stuff', timeout=60)
275+
276+
expected_error = ('Failed executing command "stuff" on host "127.0.0.1" '
277+
'Invalid sudo password provided or sudo is not configured for '
278+
'this user (bar)')
279+
280+
self.assertTrue('127.0.0.1' in results)
281+
self.assertEqual(results['127.0.0.1']['succeeded'], False)
282+
self.assertEqual(results['127.0.0.1']['failed'], True)
283+
self.assertTrue(expected_error in results['127.0.0.1']['error'])

st2api/st2api/controllers/v1/actionexecutions.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ def get_one(self, id, output_type=None, requester_user=None):
313313
execution_id = str(execution_db.id)
314314

315315
query_filters = {}
316-
if output_type:
316+
if output_type and output_type != 'all':
317317
query_filters['output_type'] = output_type
318318

319319
def existing_output_iter():

0 commit comments

Comments
 (0)