-
Notifications
You must be signed in to change notification settings - Fork 166
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
hooks: add hook to run arbitrary commands (#565)
* hooks: add hook to run arbitrary commands * hooks: command: fail if `quiet` and `capture` options are both enabled * hooks: command: log command line before executing
- Loading branch information
1 parent
1884913
commit d4eccc3
Showing
2 changed files
with
277 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
import logging | ||
import os | ||
from subprocess import PIPE, Popen | ||
|
||
from stacker.exceptions import ImproperlyConfigured | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
def _devnull(): | ||
return open(os.devnull, 'wb') | ||
|
||
|
||
def run_command(provider, context, command, capture=False, interactive=False, | ||
ignore_status=False, quiet=False, stdin=None, env=None, | ||
**kwargs): | ||
"""Run a custom command as a hook | ||
Keyword Arguments: | ||
command (list or str): | ||
Command to run | ||
capture (bool, optional): | ||
If enabled, capture the command's stdout and stderr, and return | ||
them in the hook result. Default: false | ||
interactive (bool, optional): | ||
If enabled, allow the command to interact with stdin. Otherwise, | ||
stdin will be set to the null device. Default: false | ||
ignore_status (bool, optional): | ||
Don't fail the hook if the command returns a non-zero status. | ||
Default: false | ||
quiet (bool, optional): | ||
Redirect the command's stdout and stderr to the null device, | ||
silencing all output. Should not be enaled if `capture` is also | ||
enabled. Default: false | ||
stdin (str, optional): | ||
String to send to the stdin of the command. Implicitly disables | ||
`interactive`. | ||
env (dict, optional): | ||
Dictionary of environment variable overrides for the command | ||
context. Will be merged with the current environment. | ||
**kwargs: | ||
Any other arguments will be forwarded to the `subprocess.Popen` | ||
function. Interesting ones include: `cwd` and `shell`. | ||
Examples: | ||
.. code-block:: yaml | ||
pre_build: | ||
- path: stacker.hooks.command.run_command | ||
required: true | ||
data_key: copy_env | ||
args: | ||
command: ['cp', 'environment.template', 'environment'] | ||
- path: stacker.hooks.command.run_command | ||
required: true | ||
data_key: get_git_commit | ||
args: | ||
command: ['git', 'rev-parse', 'HEAD'] | ||
cwd: ./my-git-repo | ||
capture: true | ||
- path: stacker.hooks.command.run_command | ||
args: | ||
command: `cd $PROJECT_DIR/project; npm install' | ||
env: | ||
PROJECT_DIR: ./my-project | ||
shell: true | ||
""" | ||
|
||
if quiet and capture: | ||
raise ImproperlyConfigured( | ||
__name__ + '.run_command', | ||
'Cannot enable `quiet` and `capture` options simultaneously') | ||
|
||
if quiet: | ||
out_err_type = _devnull() | ||
elif capture: | ||
out_err_type = PIPE | ||
else: | ||
out_err_type = None | ||
|
||
if interactive: | ||
in_type = None | ||
elif stdin: | ||
in_type = PIPE | ||
else: | ||
in_type = _devnull() | ||
|
||
if env: | ||
full_env = os.environ.copy() | ||
full_env.update(env) | ||
env = full_env | ||
|
||
logger.info('Running command: %s', command) | ||
|
||
proc = Popen(command, stdin=in_type, stdout=out_err_type, | ||
stderr=out_err_type, env=env, **kwargs) | ||
try: | ||
out, err = proc.communicate(stdin) | ||
status = proc.wait() | ||
|
||
if status == 0 or ignore_status: | ||
return { | ||
'returncode': proc.returncode, | ||
'stdout': out, | ||
'stderr': err | ||
} | ||
|
||
# Don't print the command line again if we already did earlier | ||
if logger.isEnabledFor(logging.INFO): | ||
logger.warn('Command failed with returncode %d', status) | ||
else: | ||
logger.warn('Command failed with returncode %d: %s', status, | ||
command) | ||
|
||
return None | ||
finally: | ||
if proc.returncode is None: | ||
proc.kill() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,159 @@ | ||
import os | ||
import unittest | ||
from subprocess import PIPE | ||
|
||
import mock | ||
|
||
from stacker.context import Context | ||
from stacker.config import Config | ||
from stacker.hooks.command import run_command | ||
|
||
from ..factories import mock_provider | ||
|
||
|
||
class MockProcess(object): | ||
def __init__(self, returncode=0, stdout='', stderr=''): | ||
self.returncode = returncode | ||
self.stdout = stdout | ||
self.stderr = stderr | ||
self.stdin = None | ||
|
||
def communicate(self, stdin): | ||
self.stdin = stdin | ||
return (self.stdout, self.stderr) | ||
|
||
def wait(self): | ||
return self.returncode | ||
|
||
def kill(self): | ||
return | ||
|
||
|
||
class TestCommandHook(unittest.TestCase): | ||
def setUp(self): | ||
self.context = Context( | ||
config=Config({'namespace': 'test', 'stacker_bucket': 'test'})) | ||
self.provider = mock_provider(region="us-east-1") | ||
|
||
self.mock_process = MockProcess() | ||
self.popen_mock = \ | ||
mock.patch('stacker.hooks.command.Popen', | ||
return_value=self.mock_process).start() | ||
|
||
self.devnull = mock.Mock() | ||
self.devnull_mock = \ | ||
mock.patch('stacker.hooks.command._devnull', | ||
return_value=self.devnull).start() | ||
|
||
def tearDown(self): | ||
self.devnull_mock.stop() | ||
self.popen_mock.stop() | ||
|
||
def run_hook(self, **kwargs): | ||
real_kwargs = { | ||
'context': self.context, | ||
'provider': self.provider, | ||
} | ||
real_kwargs.update(kwargs) | ||
|
||
return run_command(**real_kwargs) | ||
|
||
def test_command_ok(self): | ||
self.mock_process.returncode = 0 | ||
self.mock_process.stdout = None | ||
self.mock_process.stderr = None | ||
|
||
results = self.run_hook(command=['foo']) | ||
|
||
self.assertEqual( | ||
results, {'returncode': 0, 'stdout': None, 'stderr': None}) | ||
self.popen_mock.assert_called_once_with( | ||
['foo'], stdin=self.devnull, stdout=None, stderr=None, env=None) | ||
|
||
def test_command_fail(self): | ||
self.mock_process.returncode = 1 | ||
self.mock_process.stdout = None | ||
self.mock_process.stderr = None | ||
|
||
results = self.run_hook(command=['foo']) | ||
|
||
self.assertEqual(results, None) | ||
self.popen_mock.assert_called_once_with( | ||
['foo'], stdin=self.devnull, stdout=None, stderr=None, env=None) | ||
|
||
def test_command_ignore_status(self): | ||
self.mock_process.returncode = 1 | ||
self.mock_process.stdout = None | ||
self.mock_process.stderr = None | ||
|
||
results = self.run_hook(command=['foo'], ignore_status=True) | ||
|
||
self.assertEqual( | ||
results, {'returncode': 1, 'stdout': None, 'stderr': None}) | ||
self.popen_mock.assert_called_once_with( | ||
['foo'], stdin=self.devnull, stdout=None, stderr=None, env=None) | ||
|
||
def test_command_quiet(self): | ||
self.mock_process.returncode = 0 | ||
self.mock_process.stdout = None | ||
self.mock_process.stderr = None | ||
|
||
results = self.run_hook(command=['foo'], quiet=True) | ||
self.assertEqual( | ||
results, {'returncode': 0, 'stdout': None, 'stderr': None}) | ||
|
||
self.popen_mock.assert_called_once_with( | ||
['foo'], stdin=self.devnull, stdout=self.devnull, | ||
stderr=self.devnull, env=None) | ||
|
||
def test_command_interactive(self): | ||
self.mock_process.returncode = 0 | ||
self.mock_process.stdout = None | ||
self.mock_process.stderr = None | ||
|
||
results = self.run_hook(command=['foo'], interactive=True) | ||
self.assertEqual( | ||
results, {'returncode': 0, 'stdout': None, 'stderr': None}) | ||
|
||
self.popen_mock.assert_called_once_with( | ||
['foo'], stdin=None, stdout=None, stderr=None, env=None) | ||
|
||
def test_command_input(self): | ||
self.mock_process.returncode = 0 | ||
self.mock_process.stdout = None | ||
self.mock_process.stderr = None | ||
|
||
results = self.run_hook(command=['foo'], stdin='hello world') | ||
self.assertEqual( | ||
results, {'returncode': 0, 'stdout': None, 'stderr': None}) | ||
|
||
self.popen_mock.assert_called_once_with( | ||
['foo'], stdin=PIPE, stdout=None, stderr=None, env=None) | ||
self.assertEqual(self.mock_process.stdin, 'hello world') | ||
|
||
def test_command_capture(self): | ||
self.mock_process.returncode = 0 | ||
self.mock_process.stdout = 'hello' | ||
self.mock_process.stderr = 'world' | ||
|
||
results = self.run_hook(command=['foo'], capture=True) | ||
self.assertEqual( | ||
results, {'returncode': 0, 'stdout': 'hello', 'stderr': 'world'}) | ||
|
||
self.popen_mock.assert_called_once_with( | ||
['foo'], stdin=self.devnull, stdout=PIPE, stderr=PIPE, env=None) | ||
|
||
def test_command_env(self): | ||
self.mock_process.returncode = 0 | ||
self.mock_process.stdout = None | ||
self.mock_process.stderr = None | ||
|
||
with mock.patch.dict(os.environ, {'foo': 'bar'}, clear=True): | ||
results = self.run_hook(command=['foo'], env={'hello': 'world'}) | ||
|
||
self.assertEqual(results, {'returncode': 0, | ||
'stdout': None, | ||
'stderr': None}) | ||
self.popen_mock.assert_called_once_with( | ||
['foo'], stdin=self.devnull, stdout=None, stderr=None, | ||
env={'hello': 'world', 'foo': 'bar'}) |