Support Terraform 0.14

This commit is contained in:
Francois Lebreau 2021-02-28 13:44:58 +00:00
parent 785e9a3ce7
commit 7080f38a93
3 changed files with 135 additions and 150 deletions

View file

@ -4,7 +4,7 @@ import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from typing import Callable, Dict, List, Optional, Sequence, Tuple, Type, TypeVar, Union from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, Union
from python_terraform.tfstate import Tfstate from python_terraform.tfstate import Tfstate
@ -33,6 +33,7 @@ class TerraformCommandError(subprocess.CalledProcessError):
super(TerraformCommandError, self).__init__(ret_code, cmd) super(TerraformCommandError, self).__init__(ret_code, cmd)
self.out = out self.out = out
self.err = err self.err = err
logger.error("Error with command %s. Reason: %s", self.cmd, self.err)
class Terraform: class Terraform:
@ -46,7 +47,7 @@ class Terraform:
working_dir: Optional[str] = None, working_dir: Optional[str] = None,
targets: Optional[Sequence[str]] = None, targets: Optional[Sequence[str]] = None,
state: Optional[str] = None, state: Optional[str] = None,
variables: Optional[Sequence[str]] = None, variables: Optional[Dict[str, str]] = None,
parallelism: Optional[str] = None, parallelism: Optional[str] = None,
var_file: Optional[str] = None, var_file: Optional[str] = None,
terraform_bin_path: Optional[str] = None, terraform_bin_path: Optional[str] = None,
@ -114,28 +115,30 @@ class Terraform:
""" """
if not skip_plan: if not skip_plan:
return self.plan(dir_or_plan=dir_or_plan, **kwargs) return self.plan(dir_or_plan=dir_or_plan, **kwargs)
default = kwargs default = kwargs.copy()
default["input"] = input default["input"] = input
default["no_color"] = no_color default["no_color"] = no_color
default["auto-approve"] = True default["auto-approve"] = True # a False value will require an input
option_dict = self._generate_default_options(default) option_dict = self._generate_default_options(default)
args = self._generate_default_args(dir_or_plan) args = self._generate_default_args(dir_or_plan)
return self.cmd("apply", *args, **option_dict) return self.cmd("apply", *args, **option_dict)
def _generate_default_args(self, dir_or_plan) -> Sequence[str]: def _generate_default_args(self, dir_or_plan: Optional[str]) -> Sequence[str]:
return [dir_or_plan] if dir_or_plan else [] return [dir_or_plan] if dir_or_plan else []
def _generate_default_options(self, input_options): def _generate_default_options(
option_dict = dict() self, input_options: Dict[str, Any]
option_dict["state"] = self.state ) -> Dict[str, Any]:
option_dict["target"] = self.targets return {
option_dict["var"] = self.variables "state": self.state,
option_dict["var_file"] = self.var_file "target": self.targets,
option_dict["parallelism"] = self.parallelism "var": self.variables,
option_dict["no_color"] = IsFlagged "var_file": self.var_file,
option_dict["input"] = False "parallelism": self.parallelism,
option_dict.update(input_options) "no_color": IsFlagged,
return option_dict "input": False,
**input_options,
}
def destroy( def destroy(
self, self,
@ -148,7 +151,7 @@ class Terraform:
force/no-color option is flagged by default force/no-color option is flagged by default
:return: ret_code, stdout, stderr :return: ret_code, stdout, stderr
""" """
default = kwargs default = kwargs.copy()
default["force"] = force default["force"] = force
options = self._generate_default_options(default) options = self._generate_default_options(default)
args = self._generate_default_args(dir_or_plan) args = self._generate_default_args(dir_or_plan)
@ -167,7 +170,7 @@ class Terraform:
:param kwargs: options :param kwargs: options
:return: ret_code, stdout, stderr :return: ret_code, stdout, stderr
""" """
options = kwargs options = kwargs.copy()
options["detailed_exitcode"] = detailed_exitcode options["detailed_exitcode"] = detailed_exitcode
options = self._generate_default_options(options) options = self._generate_default_options(options)
args = self._generate_default_args(dir_or_plan) args = self._generate_default_args(dir_or_plan)
@ -196,10 +199,14 @@ class Terraform:
:param kwargs: options :param kwargs: options
:return: ret_code, stdout, stderr :return: ret_code, stdout, stderr
""" """
options = kwargs options = kwargs.copy()
options["backend_config"] = backend_config options.update(
options["reconfigure"] = reconfigure {
options["backend"] = backend "backend_config": backend_config,
"reconfigure": reconfigure,
"backend": backend,
}
)
options = self._generate_default_options(options) options = self._generate_default_options(options)
args = self._generate_default_args(dir_or_plan) args = self._generate_default_args(dir_or_plan)
return self.cmd("init", *args, **options) return self.cmd("init", *args, **options)
@ -281,7 +288,7 @@ class Terraform:
cmd: str, cmd: str,
*args, *args,
capture_output: Union[bool, str] = True, capture_output: Union[bool, str] = True,
raise_on_error: bool = False, raise_on_error: bool = True,
synchronous: bool = True, synchronous: bool = True,
**kwargs, **kwargs,
) -> CommandOutput: ) -> CommandOutput:
@ -322,7 +329,7 @@ class Terraform:
stdout = sys.stdout stdout = sys.stdout
cmds = self.generate_cmd_string(cmd, *args, **kwargs) cmds = self.generate_cmd_string(cmd, *args, **kwargs)
logger.debug("Command: %s", " ".join(cmds)) logger.info("Command: %s", " ".join(cmds))
working_folder = self.working_dir if self.working_dir else None working_folder = self.working_dir if self.working_dir else None
@ -339,7 +346,7 @@ class Terraform:
out, err = p.communicate() out, err = p.communicate()
ret_code = p.returncode ret_code = p.returncode
logger.debug("output: %s", out) logger.info("output: %s", out)
if ret_code == 0: if ret_code == 0:
self.read_state_file() self.read_state_file()

View file

@ -28,6 +28,6 @@ class Tfstate:
tf_state.tfstate_file = file_path tf_state.tfstate_file = file_path
return tf_state return tf_state
logger.debug("%s is not exist", file_path) logger.debug("%s does not exist", file_path)
return Tfstate() return Tfstate()

View file

@ -8,6 +8,7 @@ from io import StringIO
from typing import Callable from typing import Callable
import pytest import pytest
from _pytest.logging import LogCaptureFixture, caplog
from python_terraform import IsFlagged, IsNotFlagged, Terraform, TerraformCommandError from python_terraform import IsFlagged, IsNotFlagged, Terraform, TerraformCommandError
@ -42,7 +43,11 @@ CMD_CASES = [
[ [
[ [
lambda x: x.cmd( lambda x: x.cmd(
"plan", "var_to_output", no_color=IsFlagged, var={"test_var": "test"} "plan",
"var_to_output",
no_color=IsFlagged,
var={"test_var": "test"},
raise_on_error=False,
), ),
# Expected output varies by terraform version # Expected output varies by terraform version
"Plan: 0 to add, 0 to change, 0 to destroy.", "Plan: 0 to add, 0 to change, 0 to destroy.",
@ -52,35 +57,27 @@ CMD_CASES = [
"var_to_output", "var_to_output",
], ],
# try import aws instance # try import aws instance
[
lambda x: x.cmd(
"import", "aws_instance.foo", "i-abcd1234", no_color=IsFlagged
),
"",
1,
False,
"Command: terraform import -no-color aws_instance.foo i-abcd1234",
"",
],
# try import aws instance with raise_on_error
[ [
lambda x: x.cmd( lambda x: x.cmd(
"import", "import",
"aws_instance.foo", "aws_instance.foo",
"i-abcd1234", "i-abcd1234",
no_color=IsFlagged, no_color=IsFlagged,
raise_on_error=True, raise_on_error=False,
), ),
"", "",
1, 1,
True, False,
"Command: terraform import -no-color aws_instance.foo i-abcd1234", "Error: No Terraform configuration files",
"", "",
], ],
# test with space and special character in file path # test with space and special character in file path
[ [
lambda x: x.cmd( lambda x: x.cmd(
"plan", "var_to_output", out=FILE_PATH_WITH_SPACE_AND_SPACIAL_CHARS "plan",
"var_to_output",
out=FILE_PATH_WITH_SPACE_AND_SPACIAL_CHARS,
raise_on_error=False,
), ),
"", "",
0, 0,
@ -90,7 +87,9 @@ CMD_CASES = [
], ],
# test workspace command (commands with subcommand) # test workspace command (commands with subcommand)
[ [
lambda x: x.cmd("workspace", "show", no_color=IsFlagged), lambda x: x.cmd(
"workspace", "show", no_color=IsFlagged, raise_on_error=False
),
"", "",
0, 0,
False, False,
@ -114,24 +113,23 @@ def fmt_test_file(request):
return return
@pytest.fixture() # @pytest.fixture()
def string_logger(request) -> Callable[..., str]: # def string_logger(request) -> Callable[..., str]:
log_stream = StringIO() # log_stream = StringIO()
handler = logging.StreamHandler(log_stream) # handler = logging.StreamHandler(log_stream)
root_logger.addHandler(handler) # root_logger.addHandler(handler)
def td(): # def td():
root_logger.removeHandler(handler) # root_logger.removeHandler(handler)
log_stream.close() # log_stream.close()
request.addfinalizer(td) # request.addfinalizer(td)
return lambda: str(log_stream.getvalue()) # return lambda: str(log_stream.getvalue())
@pytest.fixture() @pytest.fixture()
def workspace_setup_teardown(): def workspace_setup_teardown():
""" """Fixture used in workspace related tests.
Fixture used in workspace related tests
Create and tear down a workspace Create and tear down a workspace
*Use as a contextmanager* *Use as a contextmanager*
@ -151,11 +149,9 @@ def workspace_setup_teardown():
yield wrapper yield wrapper
class TestTerraform(object): class TestTerraform:
def teardown_method(self, _) -> None: def teardown_method(self, _) -> None:
""" teardown any state that was previously setup with a setup_method """Teardown any state that was previously setup with a setup_method call."""
call.
"""
exclude = ["test_tfstate_file", "test_tfstate_file2", "test_tfstate_file3"] exclude = ["test_tfstate_file", "test_tfstate_file2", "test_tfstate_file3"]
def purge(dir: str, pattern: str) -> None: def purge(dir: str, pattern: str) -> None:
@ -190,24 +186,23 @@ class TestTerraform(object):
expected_ret_code: int, expected_ret_code: int,
expected_exception: bool, expected_exception: bool,
expected_logs: str, expected_logs: str,
string_logger: Callable[..., str], caplog: LogCaptureFixture,
folder, folder: str,
): ):
tf = Terraform(working_dir=current_path) with caplog.at_level(logging.INFO):
tf.init(folder) tf = Terraform(working_dir=current_path)
try: tf.init(folder)
ret, out, _ = method(tf) try:
assert not expected_exception ret, out, _ = method(tf)
except TerraformCommandError as e: assert not expected_exception
assert expected_exception except TerraformCommandError as e:
ret = e.returncode assert expected_exception
out = e.out ret = e.returncode
out = e.out
logs = string_logger()
logs = logs.replace("\n", "")
assert expected_output in out assert expected_output in out
assert expected_ret_code == ret assert expected_ret_code == ret
assert expected_logs in logs assert expected_logs in caplog.text
@pytest.mark.parametrize( @pytest.mark.parametrize(
("folder", "variables", "var_files", "expected_output", "options"), ("folder", "variables", "var_files", "expected_output", "options"),
@ -254,15 +249,18 @@ class TestTerraform(object):
assert expected_output in out.replace("\n", "").replace(" ", "") assert expected_output in out.replace("\n", "").replace(" ", "")
assert err == "" assert err == ""
def test_apply_with_var_file(self, string_logger): def test_apply_with_var_file(self, caplog: LogCaptureFixture):
tf = Terraform(working_dir=current_path) with caplog.at_level(logging.INFO):
tf = Terraform(working_dir=current_path)
tf.init() folder = "var_to_output"
tf.apply(var_file=os.path.join(current_path, "tfvar_file", "test.tfvars")) tf.init(folder)
logs = string_logger() tf.apply(
logs = logs.split("\n") folder,
for log in logs: var_file=os.path.join(current_path, "tfvar_files", "test.tfvars"),
if log.startswith("command: terraform apply"): )
for log in caplog.messages:
if log.startswith("Command: terraform apply"):
assert log.count("-var-file=") == 1 assert log.count("-var-file=") == 1
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -315,33 +313,23 @@ class TestTerraform(object):
out = tf.output("test_output") out = tf.output("test_output")
assert "test2" in out assert "test2" in out
@pytest.mark.parametrize(("param"), [({}), ({"module": "test2"}),]) @pytest.mark.parametrize("output_all", [True, False])
def test_output(self, param, string_logger): def test_output(self, caplog: LogCaptureFixture, output_all: bool):
tf = Terraform(working_dir=current_path, variables={"test_var": "test"}) expected_value = "test"
tf.init("var_to_output") required_output = "test_output"
tf.apply("var_to_output") with caplog.at_level(logging.INFO):
result = tf.output("test_output", **param) tf = Terraform(
regex = re.compile( working_dir=current_path, variables={"test_var": expected_value}
"terraform output (-module=test2 -json|-json -module=test2) test_output" )
) tf.init("var_to_output")
log_str = string_logger() tf.apply("var_to_output")
if param: params = tuple() if output_all else (required_output,)
assert re.search(regex, log_str), log_str result = tf.output(*params)
if output_all:
assert result[required_output]["value"] == expected_value
else: else:
assert result == "test" assert result == expected_value
assert expected_value in caplog.messages[-1]
@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"})
@ -355,22 +343,19 @@ class TestTerraform(object):
) )
def test_plan(self, plan, variables, expected_ret): def test_plan(self, plan, variables, expected_ret):
tf = Terraform(working_dir=current_path, variables=variables) tf = Terraform(working_dir=current_path, variables=variables)
ret, out, err = tf.plan(plan) tf.init(plan)
assert ret == expected_ret with pytest.raises(TerraformCommandError) as e:
tf.plan(plan)
assert (
e.value.err
== """\nError: Missing required argument\n\nThe argument "region" is required, but was not set.\n\n"""
)
def test_fmt(self, fmt_test_file): def test_fmt(self, fmt_test_file):
tf = Terraform(working_dir=current_path, variables={"test_var": "test"}) tf = Terraform(working_dir=current_path, variables={"test_var": "test"})
ret, out, err = tf.fmt(diff=True) ret, out, err = tf.fmt(diff=True)
assert ret == 0 assert ret == 0
def test_import(self, string_logger):
tf = Terraform(working_dir=current_path)
tf.import_cmd("aws_instance.foo", "i-abc1234", no_color=IsFlagged)
assert (
"Command: terraform import -no-color aws_instance.foo i-abc1234"
in string_logger()
)
def test_create_workspace(self, workspace_setup_teardown): def test_create_workspace(self, workspace_setup_teardown):
workspace_name = "test" workspace_name = "test"
with workspace_setup_teardown(workspace_name, create=False) as tf: with workspace_setup_teardown(workspace_name, create=False) as tf:
@ -378,25 +363,24 @@ class TestTerraform(object):
assert ret == 0 assert ret == 0
assert err == "" assert err == ""
def test_create_workspace_with_args(self, workspace_setup_teardown, string_logger): def test_create_workspace_with_args(self, workspace_setup_teardown, caplog):
workspace_name = "test" workspace_name = "test"
state_file_path = os.path.join( state_file_path = os.path.join(
current_path, "test_tfstate_file2", "terraform.tfstate" current_path, "test_tfstate_file2", "terraform.tfstate"
) )
with workspace_setup_teardown(workspace_name, create=False) as tf: with workspace_setup_teardown(
workspace_name, create=False
) as tf, caplog.at_level(logging.INFO):
ret, out, err = tf.create_workspace( ret, out, err = tf.create_workspace(
"test", current_path, no_color=IsFlagged "test", current_path, no_color=IsFlagged
) )
assert ret == 0 assert ret == 0
assert err == "" assert err == ""
assert (
logs = string_logger() f"Command: terraform workspace new -no-color test {current_path}"
logs = logs.replace("\n", "") in caplog.messages
expected_log = "Command: terraform workspace new -no-color test {}".format(
current_path
) )
assert expected_log in logs
def test_set_workspace(self, workspace_setup_teardown): def test_set_workspace(self, workspace_setup_teardown):
workspace_name = "test" workspace_name = "test"
@ -405,22 +389,21 @@ class TestTerraform(object):
assert ret == 0 assert ret == 0
assert err == "" assert err == ""
def test_set_workspace_with_args(self, workspace_setup_teardown, string_logger): def test_set_workspace_with_args(self, workspace_setup_teardown, caplog):
workspace_name = "test" workspace_name = "test"
with workspace_setup_teardown(workspace_name) as tf: with workspace_setup_teardown(workspace_name) as tf, caplog.at_level(
logging.INFO
):
ret, out, err = tf.set_workspace( ret, out, err = tf.set_workspace(
workspace_name, current_path, no_color=IsFlagged workspace_name, current_path, no_color=IsFlagged
) )
assert ret == 0 assert ret == 0
assert err == "" assert err == ""
assert (
logs = string_logger() f"Command: terraform workspace select -no-color test {current_path}"
logs = logs.replace("\n", "") in caplog.messages
expected_log = "Command: terraform workspace select -no-color test {}".format(
current_path
) )
assert expected_log in logs
def test_show_workspace(self, workspace_setup_teardown): def test_show_workspace(self, workspace_setup_teardown):
workspace_name = "test" workspace_name = "test"
@ -429,20 +412,16 @@ class TestTerraform(object):
assert ret == 0 assert ret == 0
assert err == "" assert err == ""
def test_show_workspace_with_no_color( def test_show_workspace_with_no_color(self, workspace_setup_teardown, caplog):
self, workspace_setup_teardown, string_logger
):
workspace_name = "test" workspace_name = "test"
with workspace_setup_teardown(workspace_name) as tf: with workspace_setup_teardown(workspace_name) as tf, caplog.at_level(
logging.INFO
):
ret, out, err = tf.show_workspace(no_color=IsFlagged) ret, out, err = tf.show_workspace(no_color=IsFlagged)
assert ret == 0 assert ret == 0
assert err == "" assert err == ""
assert "Command: terraform workspace show -no-color" in caplog.messages
logs = string_logger()
logs = logs.replace("\n", "")
expected_log = "Command: terraform workspace show -no-color"
assert expected_log in logs
def test_delete_workspace(self, workspace_setup_teardown): def test_delete_workspace(self, workspace_setup_teardown):
workspace_name = "test" workspace_name = "test"
@ -452,9 +431,11 @@ class TestTerraform(object):
assert ret == 0 assert ret == 0
assert err == "" assert err == ""
def test_delete_workspace_with_args(self, workspace_setup_teardown, string_logger): def test_delete_workspace_with_args(self, workspace_setup_teardown, caplog):
workspace_name = "test" workspace_name = "test"
with workspace_setup_teardown(workspace_name, delete=False) as tf: with workspace_setup_teardown(
workspace_name, delete=False
) as tf, caplog.at_level(logging.INFO):
tf.set_workspace("default") tf.set_workspace("default")
ret, out, err = tf.delete_workspace( ret, out, err = tf.delete_workspace(
workspace_name, current_path, force=IsFlagged, workspace_name, current_path, force=IsFlagged,
@ -462,10 +443,7 @@ class TestTerraform(object):
assert ret == 0 assert ret == 0
assert err == "" assert err == ""
assert (
logs = string_logger() f"Command: terraform workspace delete -force test {current_path}"
logs = logs.replace("\n", "") in caplog.messages
expected_log = "Command: terraform workspace delete -force test {}".format(
current_path
) )
assert expected_log in logs