Create base tool to execute shell commands

Change-Id: I42c551a0d3a36a6ddce7cb42ad69c694cdb1cad8
This commit is contained in:
Federico Ressi 2019-04-08 14:57:26 +02:00
parent dbfaad99bd
commit 04c3e72748
8 changed files with 364 additions and 7 deletions

View File

@ -26,7 +26,8 @@ LOG = log.getLogger(__name__)
CONFIG_MODULES = ['tobiko.openstack.keystone.config', CONFIG_MODULES = ['tobiko.openstack.keystone.config',
'tobiko.openstack.neutron.config', 'tobiko.openstack.neutron.config',
'tobiko.openstack.nova.config'] 'tobiko.openstack.nova.config',
'tobiko.shell.sh.config']
CONFIG_DIRS = [os.getcwd(), CONFIG_DIRS = [os.getcwd(),
os.path.expanduser("~/.tobiko"), os.path.expanduser("~/.tobiko"),
@ -88,12 +89,6 @@ def init_tobiko_config(default_config_dirs=None, product_name='tobiko',
def register_tobiko_options(conf): def register_tobiko_options(conf):
conf.register_opts(
group=cfg.OptGroup('shell'),
opts=[cfg.StrOpt('command',
help="Default shell command used for executing "
"local commands")])
conf.register_opts( conf.register_opts(
group=cfg.OptGroup('http'), group=cfg.OptGroup('http'),
opts=[cfg.StrOpt('http_proxy', opts=[cfg.StrOpt('http_proxy',

0
tobiko/shell/__init__.py Normal file
View File

View File

@ -0,0 +1,27 @@
# Copyright (c) 2019 Red Hat, Inc.
#
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from __future__ import absolute_import
from tobiko.shell.sh import _exception
from tobiko.shell.sh import _execute
ShellCommandFailed = _exception.ShellCommandFailed
ShellError = _exception.ShellError
ShellTimeoutExpired = _exception.ShellTimeoutExpired
execute = _execute.execute
ShellExecuteResult = _execute.ShellExecuteResult

View File

@ -0,0 +1,38 @@
# Copyright (c) 2019 Red Hat, Inc.
#
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from __future__ import absolute_import
import tobiko
class ShellError(tobiko.TobikoException):
pass
class ShellCommandFailed(ShellError):
"""Raised when shell command exited with non-zero status
"""
message = ("Command %(command)r failed, exit status: %(exit_status)d, "
"stderr:\n%(stderr)s\n"
"stdout:\n%(stdout)s")
class ShellTimeoutExpired(ShellError):
"""Raised when shell command timeouts and has been killed before exiting
"""
message = ("Command '%(command)s' timed out: %(timeout)d, "
"stderr:\n%(stderr)s\n"
"stdout:\n%(stdout)s")

157
tobiko/shell/sh/_execute.py Normal file
View File

@ -0,0 +1,157 @@
# Copyright (c) 2019 Red Hat, Inc.
#
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from __future__ import absolute_import
import collections
import subprocess
import sys
from oslo_log import log
import six
from tobiko.shell.sh import _exception
LOG = log.getLogger(__name__)
def execute(command, timeout=None, shell=None, check=True, ssh_client=None):
"""Execute command inside a remote or local shell
:param command: command argument list
:param timeout: command execution timeout in seconds
:param check: when False it doesn't raises ShellCommandError when
exit status is not zero. True by default
:param ssh_client: SSH client instance used for remote shell execution
:returns: STDOUT text when command execution terminates with zero exit
status.
:raises ShellTimeoutExpired: when timeout expires before command execution
terminates. In such case it kills the process, then it eventually would
try to read STDOUT and STDERR buffers (not fully implemented) before
raising the exception.
:raises ShellCommandError: when command execution terminates with non-zero
exit status.
"""
if timeout:
timeout = float(timeout)
if ssh_client:
result = execute_remote_command(command=command, timeout=timeout,
shell=shell, ssh_client=ssh_client)
else:
result = execute_local_command(command=command, timeout=timeout,
shell=shell)
if result.exit_status == 0:
LOG.debug("Command %r succeeded:\n"
"stderr:\n%s\n"
"stdout:\n%s\n",
command, result.stderr, result.stdout)
elif result.exit_status is None:
LOG.debug("Command %r timeout expired (timeout=%s):\n"
"stderr:\n%s\n"
"stdout:\n%s\n",
command, timeout, result.stderr, result.stdout)
else:
LOG.debug("Command %r failed (exit_status=%s):\n"
"stderr:\n%s\n"
"stdout:\n%s\n",
command, result.exit_status, result.stderr, result.stdout)
if check:
result.check()
return result
def execute_remote_command(command, ssh_client, timeout=None, shell=None):
"""Execute command on a remote host using SSH client"""
raise NotImplementedError
def execute_local_command(command, timeout=None, shell=None):
"""Execute command on local host using local shell"""
LOG.debug("Executing command %r on local host (timeout=%r)...",
command, timeout)
if not shell:
from tobiko import config
CONF = config.CONF
shell = CONF.tobiko.shell.command
if isinstance(command, six.string_types):
command = command.split()
else:
command = [str(a) for a in command]
if shell:
command = shell.split() + [str(subprocess.list2cmdline(command))]
process = subprocess.Popen(command,
universal_newlines=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
if timeout and sys.version_info < (3, 3):
LOG.warning("Popen.communicate method doens't support for timeout "
"on Python %r", sys.version)
timeout = None
# Wait for process execution while reading STDERR and STDOUT streams
if timeout:
try:
stdout, stderr = process.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
# At this state I expect the process to be still running
# therefore it has to be kill later after calling poll()
LOG.exception("Command %r timeout expired.", command)
stdout = stderr = None
else:
stdout, stderr = process.communicate()
# Check process termination status
exit_status = process.poll()
if exit_status is None:
# The process is still running after calling communicate():
# let kill it
process.kill()
return ShellExecuteResult(command=command, timeout=timeout,
stdout=stdout, stderr=stderr,
exit_status=exit_status)
class ShellExecuteResult(collections.namedtuple(
'ShellExecuteResult', ['command', 'timeout', 'exit_status', 'stdout',
'stderr'])):
def check(self):
if self.exit_status is None:
raise _exception.ShellTimeoutExpired(command=self.command,
timeout=self.timeout,
stderr=self.stderr,
stdout=self.stdout)
elif self.exit_status != 0:
raise _exception.ShellCommandFailed(command=self.command,
exit_status=self.exit_status,
stderr=self.stderr,
stdout=self.stdout)

28
tobiko/shell/sh/config.py Normal file
View File

@ -0,0 +1,28 @@
# Copyright (c) 2019 Red Hat, Inc.
#
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from __future__ import absolute_import
from oslo_config import cfg
def register_tobiko_options(conf):
conf.register_opts(
group=cfg.OptGroup('shell'),
opts=[cfg.StrOpt('command',
default='/bin/sh -c',
help="Default shell command used for executing "
"local commands")])

View File

View File

@ -0,0 +1,112 @@
# Copyright (c) 2019 Red Hat, Inc.
#
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from __future__ import absolute_import
import sys
import unittest
from tobiko import config
from tobiko.shell import sh
from tobiko.tests import unit
CONF = config.CONF
class ExecuteTest(unit.TobikoUnitTest):
def setUp(self):
super(ExecuteTest, self).setUp()
self.config = self.patch_object(CONF.tobiko, 'shell',
command='/bin/sh -c')
def test_execute_string(self):
result = sh.execute('true')
self.assertEqual(
sh.ShellExecuteResult(
command=['/bin/sh', '-c', 'true'],
timeout=None, exit_status=0, stdout='', stderr=''),
result)
def test_execute_list(self):
result = sh.execute(['echo', 'something'])
self.assertEqual(
sh.ShellExecuteResult(
command=['/bin/sh', '-c', 'echo something'],
timeout=None, exit_status=0, stdout='something\n', stderr=''),
result)
def test_execute_writing_to_stdout(self):
result = sh.execute('echo something')
self.assertEqual(
sh.ShellExecuteResult(
command=['/bin/sh', '-c', 'echo something'],
timeout=None, exit_status=0, stdout='something\n', stderr=''),
result)
def test_execute_writing_to_stderr(self):
result = sh.execute('echo something >&2')
self.assertEqual(
sh.ShellExecuteResult(
command=['/bin/sh', '-c', 'echo something >&2'],
timeout=None, exit_status=0, stdout='', stderr='something\n'),
result)
def test_execute_failing_command(self):
ex = self.assertRaises(sh.ShellCommandFailed, sh.execute, 'exit 15')
self.assertEqual('', ex.stdout)
self.assertEqual('', ex.stderr)
self.assertEqual(15, ex.exit_status)
self.assertEqual(['/bin/sh', '-c', 'exit 15'], ex.command)
def test_execute_failing_command_writing_to_stdout(self):
ex = self.assertRaises(sh.ShellCommandFailed, sh.execute,
'echo something; exit 8')
self.assertEqual('something\n', ex.stdout)
self.assertEqual('', ex.stderr)
self.assertEqual(8, ex.exit_status)
self.assertEqual(['/bin/sh', '-c', 'echo something; exit 8'],
ex.command)
def test_execute_failing_command_writing_to_stderr(self):
ex = self.assertRaises(sh.ShellCommandFailed, sh.execute,
'echo something >&2; exit 7')
self.assertEqual('', ex.stdout)
self.assertEqual('something\n', ex.stderr)
self.assertEqual(7, ex.exit_status)
self.assertEqual(['/bin/sh', '-c', 'echo something >&2; exit 7'],
ex.command)
def test_execute_with_timeout(self):
result = sh.execute('true', timeout=30.)
expected_timeout = None if sys.version_info < (3, 3) else 30.
self.assertEqual(
sh.ShellExecuteResult(
command=['/bin/sh', '-c', 'true'],
timeout=expected_timeout, exit_status=0, stdout='',
stderr=''),
result)
@unittest.skipIf(sys.version_info < (3, 3),
'not implemented for Python version < 3.3')
def test_execute_with_timeout_expired(self):
ex = self.assertRaises(sh.ShellTimeoutExpired, sh.execute,
'echo out; echo err >&2; sleep 30',
timeout=.01)
self.assertEqual(['/bin/sh', '-c',
'echo out; echo err >&2; sleep 30'],
ex.command)