Skip to content

Commit

Permalink
hooks: add hook to run arbitrary commands (#565)
Browse files Browse the repository at this point in the history
* 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
danielkza authored and phobologic committed Jun 2, 2018
1 parent 1884913 commit d4eccc3
Show file tree
Hide file tree
Showing 2 changed files with 277 additions and 0 deletions.
118 changes: 118 additions & 0 deletions stacker/hooks/command.py
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()
159 changes: 159 additions & 0 deletions stacker/tests/hooks/test_command.py
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'})

0 comments on commit d4eccc3

Please sign in to comment.