Add full support for 'output' command, and enable raise_on_error option

Add a general "raise_on_error" option to all terraform commands. If provided and
set to anything that evaluates to True, then TerraformCommandError (a subclass of
subprocess.CalledProcessError) will be raised if the returncode is not 0. The exception
object will have the following special proerties:
    returncode: The returncode from the command, as in subprocess.CalledProcessError.
    out:        The contents of stdout if available, otherwise None
    err:        The contents of stderr if available, otherwise None

Terraform.output() no longer requires an argument for the output name; if omitted, it
returns a dict of all outputs, exactly as expected from 'terraform output -json'.

Terraform.output() now accepts an optional "full_value" option. If provided and True, and
an output name was provided, then the return value will be a dict with "value", "type",
and "sensitive" fields, exactly as expected from 'terraform output -json <output-name>'

Added tests for all of this new functionality...
This commit is contained in:
Sam McKelvie 2017-10-13 13:36:25 -07:00
parent a4b36e1418
commit ec826887f5
3 changed files with 126 additions and 17 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@
/.tox/ /.tox/
.dropbox .dropbox
Icon Icon
/pytestdebug.log

View file

@ -10,6 +10,7 @@ import tempfile
from python_terraform.tfstate import Tfstate from python_terraform.tfstate import Tfstate
try: # Python 2.7+ try: # Python 2.7+
from logging import NullHandler from logging import NullHandler
except ImportError: except ImportError:
@ -29,6 +30,12 @@ class IsNotFlagged:
pass pass
class TerraformCommandError(subprocess.CalledProcessError):
def __init__(self, ret_code, cmd, out, err):
super(TerraformCommandError, self).__init__(ret_code, cmd)
self.out = out
self.err = err
class Terraform(object): class Terraform(object):
""" """
Wrapper of terraform command line tool Wrapper of terraform command line tool
@ -252,10 +259,17 @@ class Terraform(object):
if the option 'capture_output' is passed (with any value other than if the option 'capture_output' is passed (with any value other than
True), terraform output will be printed to stdout/stderr and True), terraform output will be printed to stdout/stderr and
"None" will be returned as out and err. "None" will be returned as out and err.
if the option 'raise_on_error' is passed (with any value that evaluates to True),
and the terraform command returns a nonzerop return code, then
a TerraformCommandError exception will be raised. The exception object will
have the following properties:
returncode: The command's return code
out: The captured stdout, or None if not captured
err: The captured stderr, or None if not captured
:return: ret_code, out, err :return: ret_code, out, err
""" """
capture_output = kwargs.pop('capture_output', True) capture_output = kwargs.pop('capture_output', True)
raise_on_error = kwargs.pop('raise_on_error', False)
if capture_output is True: if capture_output is True:
stderr = subprocess.PIPE stderr = subprocess.PIPE
stdout = subprocess.PIPE stdout = subprocess.PIPE
@ -285,27 +299,61 @@ class Terraform(object):
self.temp_var_files.clean_up() self.temp_var_files.clean_up()
if capture_output is True: if capture_output is True:
return ret_code, out.decode('utf-8'), err.decode('utf-8') out = out.decode('utf-8')
err = err.decode('utf-8')
else: else:
return ret_code, None, None out = None
err = None
def output(self, name, *args, **kwargs): if ret_code != 0 and raise_on_error:
raise TerraformCommandError(
ret_code, ' '.join(cmds), out=out, err=err)
return ret_code, out, err
def output(self, *args, **kwargs):
""" """
https://www.terraform.io/docs/commands/output.html https://www.terraform.io/docs/commands/output.html
:param name: name of output
:return: output value Note that this method does not conform to the (ret_code, out, err) return convention. To use
the "output" command with the standard convention, call "output_cmd" instead of
"output".
:param args: Positional arguments. There is one optional positional
argument NAME; if supplied, the returned output text
will be the json for a single named output value.
:param kwargs: Named options, passed to the command. In addition,
'full_value': If True, and NAME is provided, then
the return value will be a dict with
"value', 'type', and 'sensitive'
properties.
:return: None, if an error occured
Output value as a string, if NAME is provided and full_value
is False or not provided
Output value as a dict with 'value', 'sensitive', and 'type' if
NAME is provided and full_value is True.
dict of named dicts each with 'value', 'sensitive', and 'type',
if NAME is not provided
""" """
full_value = kwargs.pop('full_value', False)
name_provided = (len(args) > 0)
kwargs['json'] = IsFlagged
kwargs['capture_output'] = True
ret, out, err = self.cmd( ret, out, err = self.output_cmd(*args, **kwargs)
'output', name, json=IsFlagged, *args, **kwargs)
log.debug('output raw string: {0}'.format(out))
if ret != 0: if ret != 0:
return None return None
out = out.lstrip() out = out.lstrip()
output_dict = json.loads(out) value = json.loads(out)
return output_dict['value']
if name_provided and not full_value:
value = value['value']
return value
def read_state_file(self, file_path=None): def read_state_file(self, file_path=None):
""" """

View file

@ -12,6 +12,7 @@ import fnmatch
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
root_logger = logging.getLogger() root_logger = logging.getLogger()
current_path = os.path.dirname(os.path.realpath(__file__)) current_path = os.path.dirname(os.path.realpath(__file__))
FILE_PATH_WITH_SPACE_AND_SPACIAL_CHARS = "test 'test.out!" FILE_PATH_WITH_SPACE_AND_SPACIAL_CHARS = "test 'test.out!"
@ -30,12 +31,14 @@ STRING_CASES = [
] ]
CMD_CASES = [ CMD_CASES = [
['method', 'expected_output', 'expected_ret_code', 'expected_logs', 'folder'], ['method', 'expected_output', 'expected_ret_code', 'expected_exception', 'expected_logs', 'folder'],
[ [
[ [
lambda x: x.cmd('plan', 'var_to_output', no_color=IsFlagged, var={'test_var': 'test'}) , lambda x: x.cmd('plan', 'var_to_output', no_color=IsFlagged, var={'test_var': 'test'}) ,
"doesn't need to do anything", #"doesn't need to do anything",
"no\nactions need to be performed",
0, 0,
False,
'', '',
'var_to_output' 'var_to_output'
], ],
@ -44,6 +47,16 @@ CMD_CASES = [
lambda x: x.cmd('import', 'aws_instance.foo', 'i-abcd1234', no_color=IsFlagged), lambda x: x.cmd('import', 'aws_instance.foo', 'i-abcd1234', no_color=IsFlagged),
'', '',
1, 1,
False,
'command: terraform import -no-color aws_instance.foo i-abcd1234',
''
],
# try import aws instance with raise_on_error
[
lambda x: x.cmd('import', 'aws_instance.foo', 'i-abcd1234', no_color=IsFlagged, raise_on_error=True),
'',
1,
True,
'command: terraform import -no-color aws_instance.foo i-abcd1234', 'command: terraform import -no-color aws_instance.foo i-abcd1234',
'' ''
], ],
@ -52,6 +65,7 @@ CMD_CASES = [
lambda x: x.cmd('plan', 'var_to_output', out=FILE_PATH_WITH_SPACE_AND_SPACIAL_CHARS), lambda x: x.cmd('plan', 'var_to_output', out=FILE_PATH_WITH_SPACE_AND_SPACIAL_CHARS),
'', '',
0, 0,
False,
'', '',
'var_to_output' 'var_to_output'
] ]
@ -123,10 +137,18 @@ class TestTerraform(object):
assert s in result assert s in result
@pytest.mark.parametrize(*CMD_CASES) @pytest.mark.parametrize(*CMD_CASES)
def test_cmd(self, method, expected_output, expected_ret_code, expected_logs, string_logger, folder): def test_cmd(self, method, expected_output, expected_ret_code, expected_exception, expected_logs, string_logger, folder):
tf = Terraform(working_dir=current_path) tf = Terraform(working_dir=current_path)
tf.init(folder) tf.init(folder)
ret, out, err = method(tf) try:
ret, out, err = method(tf)
assert not expected_exception
except TerraformCommandError as e:
assert expected_exception
ret = e.returncode
out = e.out
err = e.err
logs = string_logger() logs = string_logger()
logs = logs.replace('\n', '') logs = logs.replace('\n', '')
assert expected_output in out assert expected_output in out
@ -223,6 +245,44 @@ class TestTerraform(object):
else: else:
assert result == 'test' assert result == 'test'
@pytest.mark.parametrize(
("param"),
[
({}),
({'module': 'test2'}),
]
)
def test_output_full_value(self, param, string_logger):
tf = Terraform(working_dir=current_path, variables={'test_var': 'test'})
tf.init('var_to_output')
tf.apply('var_to_output')
result = tf.output('test_output', **dict(param, full_value=True))
regex = re.compile("terraform output (-module=test2 -json|-json -module=test2) test_output")
log_str = string_logger()
if param:
assert re.search(regex, log_str), log_str
else:
assert result['value'] == 'test'
@pytest.mark.parametrize(
("param"),
[
({}),
({'module': 'test2'}),
]
)
def test_output_all(self, param, string_logger):
tf = Terraform(working_dir=current_path, variables={'test_var': 'test'})
tf.init('var_to_output')
tf.apply('var_to_output')
result = tf.output(**param)
regex = re.compile("terraform output (-module=test2 -json|-json -module=test2)")
log_str = string_logger()
if param:
assert re.search(regex, log_str), log_str
else:
assert result['test_output']['value'] == 'test'
def test_destroy(self): def test_destroy(self):
tf = Terraform(working_dir=current_path, variables={'test_var': 'test'}) tf = Terraform(working_dir=current_path, variables={'test_var': 'test'})
tf.init('var_to_output') tf.init('var_to_output')