diff --git a/leverage/container.py b/leverage/container.py index 1dc4f52..d12a5b7 100644 --- a/leverage/container.py +++ b/leverage/container.py @@ -82,12 +82,12 @@ def __init__(self, client, mounts: tuple = None, env_vars: dict = None): self.project = self.paths.project # Set image to use - self.image = self.env_conf.get("TERRAFORM_IMAGE", self.LEVERAGE_IMAGE) - self.image_tag = self.env_conf.get("TERRAFORM_IMAGE_TAG") + self.image = self.env_conf.get("TF_IMAGE", self.env_conf.get("TERRAFORM_IMAGE", self.LEVERAGE_IMAGE)) + self.image_tag = self.env_conf.get("TF_IMAGE_TAG", self.env_conf.get("TERRAFORM_IMAGE_TAG")) if not self.image_tag: logger.error( "No docker image tag defined.\n" - "Please set `TERRAFORM_IMAGE_TAG` variable in the project's [bold]build.env[/bold] file before running a Leverage command." + "Please set `TF_IMAGE_TAG` variable in the project's [bold]build.env[/bold] file before running a Leverage command." ) raise Exit(1) @@ -433,15 +433,16 @@ def system_exec(self, command): return exit_code, output -class TerraformContainer(SSOContainer): - """Leverage container specifically tailored to run Terraform commands. +class TFContainer(SSOContainer): + """Leverage container specifically tailored to run Terraform/OpenTofu commands. It handles authentication and some checks regarding where the command is being executed.""" - TF_BINARY = "/bin/terraform" + TERRAFORM_BINARY = "/bin/terraform" + TOFU_BINARY = "/bin/tofu" TF_MFA_ENTRYPOINT = "/home/leverage/scripts/aws-mfa/aws-mfa-entrypoint.sh" - def __init__(self, client, mounts=None, env_vars=None): + def __init__(self, client, terraform=False, mounts=None, env_vars=None): super().__init__(client, mounts=mounts, env_vars=env_vars) self.paths.assert_running_leverage_project() @@ -474,7 +475,7 @@ def __init__(self, client, mounts=None, env_vars=None): "SSH_AUTH_SOCK": "" if SSH_AUTH_SOCK is None else "/ssh-agent", } ) - self.entrypoint = self.TF_BINARY + self.entrypoint = self.TERRAFORM_BINARY if terraform else self.TOFU_BINARY extra_mounts = [ Mount(source=self.paths.root_dir.as_posix(), target=self.paths.guest_base_path, type="bind"), Mount( @@ -685,7 +686,7 @@ def backend_key(self, backend_key): self._backend_key = backend_key -class TFautomvContainer(TerraformContainer): +class TFautomvContainer(TFContainer): """Leverage Container tailored to run general commands.""" TFAUTOMV_CLI_BINARY = "/usr/local/bin/tfautomv" diff --git a/leverage/containers/kubectl.py b/leverage/containers/kubectl.py index 926da20..8a21a8b 100644 --- a/leverage/containers/kubectl.py +++ b/leverage/containers/kubectl.py @@ -10,7 +10,7 @@ from leverage import logger from leverage._utils import AwsCredsEntryPoint, ExitError, CustomEntryPoint -from leverage.container import TerraformContainer +from leverage.container import TFContainer @dataclass @@ -24,11 +24,11 @@ class MetadataTypes(Enum): K8S_CLUSTER = "k8s-eks-cluster" -class KubeCtlContainer(TerraformContainer): +class KubeCtlContainer(TFContainer): """Container specifically tailored to run kubectl commands.""" KUBECTL_CLI_BINARY = "/usr/local/bin/kubectl" - KUBECTL_CONFIG_PATH = Path(f"/home/{TerraformContainer.CONTAINER_USER}/.kube") + KUBECTL_CONFIG_PATH = Path(f"/home/{TFContainer.CONTAINER_USER}/.kube") KUBECTL_CONFIG_FILE = KUBECTL_CONFIG_PATH / Path("config") METADATA_FILENAME = "metadata.yaml" @@ -78,7 +78,8 @@ def configure(self, ci: ClusterInfo = None): logger.info("Done.") def _get_eks_kube_config(self) -> str: - exit_code, output = self._start_with_output(f"{self.TF_BINARY} output -no-color") # TODO: override on CM? + tf_binary = self.TOFU_BINARY if "tofu" in self.image_tag else self.TERRAFORM_BINARY + exit_code, output = self._start_with_output(f"{tf_binary} output -no-color") # TODO: override on CM? if exit_code: raise ExitError(exit_code, output) diff --git a/leverage/leverage.py b/leverage/leverage.py index 42b1e6e..4810bb9 100644 --- a/leverage/leverage.py +++ b/leverage/leverage.py @@ -11,7 +11,7 @@ from leverage._internals import pass_state from leverage.modules.aws import aws from leverage.modules.credentials import credentials -from leverage.modules import run, project, terraform, tfautomv, kubectl, shell +from leverage.modules import run, project, tofu, terraform, tfautomv, kubectl, shell from leverage.path import NotARepositoryError @@ -36,11 +36,18 @@ def leverage(context, state, verbose): return # check if the current versions are lower than the minimum required - if not (current_values := config.get("TERRAFORM_IMAGE_TAG")): + if not (image_tag := config.get("TF_IMAGE_TAG", config.get("TERRAFORM_IMAGE_TAG"))): # at some points of the project (the init), the config file is not created yet return + # validate both TOOLBOX and TF versions - for key, current in zip(MINIMUM_VERSIONS, current_values.split("-")): + image_versions = image_tag.split("-") + if "tofu" not in image_versions: + versions = zip(MINIMUM_VERSIONS, image_versions) + else: + versions = {"TOOLBOX": image_versions[-1]}.items() + + for key, current in versions: if Version(current) < Version(MINIMUM_VERSIONS[key]): rich.print( f"[red]WARNING[/red]\tYour current {key} version ({current}) is lower than the required minimum ({MINIMUM_VERSIONS[key]})." @@ -50,8 +57,9 @@ def leverage(context, state, verbose): # Add modules to leverage leverage.add_command(run) leverage.add_command(project) +leverage.add_command(tofu) +leverage.add_command(tofu, name="tf") leverage.add_command(terraform) -leverage.add_command(terraform, name="tf") leverage.add_command(credentials) leverage.add_command(aws) leverage.add_command(tfautomv) diff --git a/leverage/modules/__init__.py b/leverage/modules/__init__.py index 2aebee2..49aa720 100644 --- a/leverage/modules/__init__.py +++ b/leverage/modules/__init__.py @@ -1,6 +1,6 @@ from .run import run from .project import project -from .terraform import terraform +from .tf import tofu, terraform from .tfautomv import tfautomv from .kubectl import kubectl from .shell import shell diff --git a/leverage/modules/credentials.py b/leverage/modules/credentials.py index 883a8d3..6182013 100644 --- a/leverage/modules/credentials.py +++ b/leverage/modules/credentials.py @@ -252,7 +252,7 @@ def credentials(state): if common.tfvars raise an exception - If we reached the only common.tfvars scenario, we have no project name nor TERRAFORM_IMAGE_TAG. + If we reached the only common.tfvars scenario, we have no project name nor TF_IMAGE_TAG. So the best chance is to read the common.tfvars directly without a conatiner, e.g. with sed or grep """ project_config = _load_project_yaml() @@ -266,7 +266,7 @@ def credentials(state): logger.error("Invalid or missing project short name in project.yaml file.") raise Exit(1) if not build_env.exists(): - build_env.write_text(f"PROJECT={short_name}\nTERRAFORM_IMAGE_TAG={__toolbox_version__}") + build_env.write_text(f"PROJECT={short_name}\nTF_IMAGE_TAG={__toolbox_version__}") elif not build_env.exists(): # project_config is not empty # and build.env does not exist @@ -280,7 +280,7 @@ def credentials(state): if g: found = True logger.info("Reading info from common.tfvars") - build_env.write_text(f"PROJECT={g[1]}\nTERRAFORM_IMAGE_TAG=1.1.9") + build_env.write_text(f"PROJECT={g[1]}\nTF_IMAGE_TAG=1.1.9") break if not found: raise Exception("Config file not found") diff --git a/leverage/modules/project.py b/leverage/modules/project.py index 6d5bd41..49e56c8 100644 --- a/leverage/modules/project.py +++ b/leverage/modules/project.py @@ -21,7 +21,7 @@ from leverage.path import NotARepositoryError from leverage._utils import git, ExitError from leverage.container import get_docker_client -from leverage.container import TerraformContainer +from leverage.container import TFContainer # Leverage related base definitions LEVERAGE_DIR = Path.home() / ".leverage" @@ -326,7 +326,7 @@ def create(): # Format the code correctly logger.info("Reformatting terraform configuration to the standard style.") - terraform = TerraformContainer(get_docker_client()) + terraform = TFContainer(get_docker_client()) terraform.ensure_image() terraform.disable_authentication() with console.status("Formatting..."): diff --git a/leverage/modules/shell.py b/leverage/modules/shell.py index a617f4d..bd0fadf 100644 --- a/leverage/modules/shell.py +++ b/leverage/modules/shell.py @@ -1,7 +1,7 @@ import click from leverage._utils import CustomEntryPoint -from leverage.container import get_docker_client, TerraformContainer +from leverage.container import get_docker_client, TFContainer from leverage.modules.utils import env_var_option, mount_option, auth_sso, auth_mfa @@ -28,9 +28,9 @@ def shell(mount, env_var, mfa, sso): """ if env_var: env_var = dict(env_var) - # TODO: TerraformContainer is the only class supporting sso/mfa auth automagically + # TODO: TFContainer is the only class supporting sso/mfa auth automagically # Move this capacity into a mixin later - container = TerraformContainer(get_docker_client(), mounts=mount, env_vars=env_var) + container = TFContainer(get_docker_client(), mounts=mount, env_vars=env_var) container.ensure_image() # auth diff --git a/leverage/modules/terraform.py b/leverage/modules/tf.py similarity index 89% rename from leverage/modules/terraform.py rename to leverage/modules/tf.py index 8a0f94e..8300f0c 100644 --- a/leverage/modules/terraform.py +++ b/leverage/modules/tf.py @@ -8,7 +8,7 @@ from leverage import logger from leverage._internals import pass_container, pass_state from leverage._utils import ExitError, parse_tf_file -from leverage.container import TerraformContainer +from leverage.container import TFContainer from leverage.container import get_docker_client from leverage.modules.utils import env_var_option, mount_option, auth_mfa, auth_sso @@ -19,8 +19,26 @@ # ########################################################################### -# CREATE THE TERRAFORM GROUP +# CREATE THE TOFU AND TERRAFORM GROUPS # ########################################################################### +@click.group() +@mount_option +@env_var_option +@pass_state +def tofu(state, env_var, mount): + """Run OpenTofu commands in a custom containerized environment that provides extra functionality when interacting + with your cloud provider such as handling multi factor authentication for you. + All tofu subcommands that receive extra args will pass the given strings as is to their corresponding OpenTofu + counterparts in the container. For example as in `leverage tofu apply -auto-approve` or + `leverage tofu init -reconfigure` + """ + if env_var: + env_var = dict(env_var) + + state.container = TFContainer(get_docker_client(), mounts=mount, env_vars=env_var) + state.container.ensure_image() + + @click.group() @mount_option @env_var_option @@ -35,14 +53,14 @@ def terraform(state, env_var, mount): if env_var: env_var = dict(env_var) - state.container = TerraformContainer(get_docker_client(), mounts=mount, env_vars=env_var) + state.container = TFContainer(get_docker_client(), terraform=True, mounts=mount, env_vars=env_var) state.container.ensure_image() CONTEXT_SETTINGS = {"ignore_unknown_options": True} # ########################################################################### -# CREATE THE TERRAFORM GROUP'S COMMANDS +# CREATE THE TF GROUP'S COMMANDS # ########################################################################### # # --layers is a ordered comma separated list of layer names @@ -72,20 +90,20 @@ def terraform(state, env_var, mount): ) -@terraform.command(context_settings=CONTEXT_SETTINGS) +@click.command(context_settings=CONTEXT_SETTINGS) @click.option("--skip-validation", is_flag=True, help="Skip layout validation.") @layers_option @click.argument("args", nargs=-1) @pass_container @click.pass_context -def init(context, tf: TerraformContainer, skip_validation, layers, args): +def init(context, tf: TFContainer, skip_validation, layers, args): """ Initialize this layer. """ invoke_for_all_commands(layers, _init, args, skip_validation) -@terraform.command(context_settings=CONTEXT_SETTINGS) +@click.command(context_settings=CONTEXT_SETTINGS) @layers_option @click.argument("args", nargs=-1) @pass_container @@ -95,7 +113,7 @@ def plan(context, tf, layers, args): invoke_for_all_commands(layers, _plan, args) -@terraform.command(context_settings=CONTEXT_SETTINGS) +@click.command(context_settings=CONTEXT_SETTINGS) @layers_option @click.argument("args", nargs=-1) @pass_container @@ -105,7 +123,7 @@ def apply(context, tf, layers, args): invoke_for_all_commands(layers, _apply, args) -@terraform.command(context_settings=CONTEXT_SETTINGS) +@click.command(context_settings=CONTEXT_SETTINGS) @layers_option @click.argument("args", nargs=-1) @pass_container @@ -115,7 +133,7 @@ def output(context, tf, layers, args): invoke_for_all_commands(layers, _output, args) -@terraform.command(context_settings=CONTEXT_SETTINGS) +@click.command(context_settings=CONTEXT_SETTINGS) @layers_option @click.argument("args", nargs=-1) @pass_container @@ -125,7 +143,7 @@ def destroy(context, tf, layers, args): invoke_for_all_commands(layers, _destroy, args) -@terraform.command() +@click.command() @pass_container def version(tf): """Print version.""" @@ -133,7 +151,7 @@ def version(tf): tf.start("version") -@terraform.command() +@click.command() @auth_mfa @auth_sso @pass_container @@ -149,7 +167,7 @@ def shell(tf, mfa, sso): tf.start_shell() -@terraform.command("format", context_settings=CONTEXT_SETTINGS) +@click.command("format", context_settings=CONTEXT_SETTINGS) @click.argument("args", nargs=-1) @pass_container def _format(tf, args): @@ -159,7 +177,7 @@ def _format(tf, args): tf.start("fmt", *args) -@terraform.command() +@click.command() @pass_container def validate(tf): """Validate code of the current directory. Previous initialization might be needed.""" @@ -167,7 +185,7 @@ def validate(tf): tf.start("validate") -@terraform.command("validate-layout") +@click.command("validate-layout") @pass_container def validate_layout(tf): """Validate layer conforms to Leverage convention.""" @@ -175,7 +193,7 @@ def validate_layout(tf): return _validate_layout() -@terraform.command("import") +@click.command("import") @click.argument("address") @click.argument("_id", metavar="ID") @pass_container @@ -187,7 +205,7 @@ def _import(tf, address, _id): raise Exit(exit_code) -@terraform.command("refresh-credentials") +@click.command("refresh-credentials") @pass_container def refresh_credentials(tf): """Refresh the AWS credentials used on the current layer.""" @@ -196,6 +214,28 @@ def refresh_credentials(tf): raise Exit(exit_code) +# ########################################################################### +# ATTACH SUBCOMMANDS TO TF COMMANDS +# ########################################################################### + +for subcommand in ( + init, + plan, + apply, + output, + destroy, + version, + shell, + _format, + validate, + validate_layout, + _import, + refresh_credentials, +): + tofu.add_command(subcommand) + terraform.add_command(subcommand) + + # ########################################################################### # HANDLER FOR MANAGING THE BASE COMMANDS (init, plan, apply, destroy, output) # ########################################################################### @@ -475,7 +515,7 @@ def _make_layer_backend_key(cwd, account_dir, account_name): @pass_container -def _validate_layout(tf: TerraformContainer): +def _validate_layout(tf: TFContainer): tf.paths.check_for_layer_location() # Check for `environment = ` in account.tfvars diff --git a/tests/test_conf.py b/tests/test_conf.py index 57ede97..c8f783e 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -57,20 +57,29 @@ def test_load_config(monkeypatch, click_context, tmp_path, write_files, expected assert dict(loaded_values) == expected_values -def test_version_validation(): +@pytest.mark.parametrize( + "tofu, conf", + [ + (True, {"TERRAFORM_IMAGE_TAG": "1.1.1-tofu-2.2.2"}), + (False, {"TERRAFORM_IMAGE_TAG": "1.1.1-2.2.2"}), + ], +) +def test_version_validation(tofu, conf): """ Test that we get a warning if we are working with a version lower than the required by the project. """ runner = CliRunner() with ( - mock.patch("leverage.conf.load", return_value={"TERRAFORM_IMAGE_TAG": "1.1.1-2.2.2"}), + mock.patch("leverage.conf.load", return_value=conf), mock.patch.dict("leverage.MINIMUM_VERSIONS", {"TERRAFORM": "3.3.3", "TOOLBOX": "4.4.4"}), ): result = runner.invoke(leverage) - assert "Your current TERRAFORM version (1.1.1) is lower than the required minimum (3.3.3)" in result.output.replace( - "\n", "" - ) + if not tofu: + assert ( + "Your current TERRAFORM version (1.1.1) is lower than the required minimum (3.3.3)" + in result.output.replace("\n", "") + ) assert "Your current TOOLBOX version (2.2.2) is lower than the required minimum (4.4.4)" in result.output.replace( "\n", "" ) diff --git a/tests/test_containers/test_terraform.py b/tests/test_containers/test_terraform.py deleted file mode 100644 index 76ab703..0000000 --- a/tests/test_containers/test_terraform.py +++ /dev/null @@ -1,61 +0,0 @@ -from unittest import mock - -import pytest - -from leverage.container import TerraformContainer -from tests.test_containers import container_fixture_factory - - -@pytest.fixture -def terraform_container(muted_click_context, monkeypatch): - monkeypatch.setenv("TF_PLUGIN_CACHE_DIR", "/home/testing/.terraform/cache") - return container_fixture_factory(TerraformContainer) - - -def test_tf_plugin_cache_dir(terraform_container): - """ - Given `TF_PLUGIN_CACHE_DIR` is set as an env var on the host - we expect it to be on the container too, and also as a mounted folder. - """ - # call any command to trigger a container creation - terraform_container.start_shell() - container_args = terraform_container.client.api.create_container.call_args[1] - - # make sure the env var is on place - assert container_args["environment"]["TF_PLUGIN_CACHE_DIR"] == "/home/testing/.terraform/cache" - - # and the cache folder mounted - assert next(m for m in container_args["host_config"]["Mounts"] if m["Target"] == "/home/testing/.terraform/cache") - - -@mock.patch("leverage.container.refresh_layer_credentials") -def test_refresh_credentials(mock_refresh, terraform_container): - terraform_container.enable_sso() - terraform_container.refresh_credentials() - container_args = terraform_container.client.api.create_container.call_args_list[0][1] - - # we want a shell, so -> /bin/bash and refresh_sso_credentials flag - assert container_args["command"] == 'echo "Done."' - assert mock_refresh.assert_called_once - - -@mock.patch("leverage.container.refresh_layer_credentials") -def test_auth_method_sso_enabled(mock_refresh, terraform_container): - terraform_container.sso_enabled = True - terraform_container.auth_method() - - assert mock_refresh.assert_called_once - - -def test_auth_method_mfa_enabled(terraform_container): - terraform_container.sso_enabled = False - terraform_container.mfa_enabled = True - - assert terraform_container.auth_method() == "/home/leverage/scripts/aws-mfa/aws-mfa-entrypoint.sh -- " - - -def test_auth_method_else(terraform_container): - terraform_container.sso_enabled = False - terraform_container.mfa_enabled = False - - assert terraform_container.auth_method() == "" diff --git a/tests/test_containers/test_tf.py b/tests/test_containers/test_tf.py new file mode 100644 index 0000000..49754e4 --- /dev/null +++ b/tests/test_containers/test_tf.py @@ -0,0 +1,61 @@ +from unittest import mock + +import pytest + +from leverage.container import TFContainer +from tests.test_containers import container_fixture_factory + + +@pytest.fixture +def tf_container(muted_click_context, monkeypatch): + monkeypatch.setenv("TF_PLUGIN_CACHE_DIR", "/home/testing/.terraform/cache") + return container_fixture_factory(TFContainer) + + +def test_tf_plugin_cache_dir(tf_container): + """ + Given `TF_PLUGIN_CACHE_DIR` is set as an env var on the host + we expect it to be on the container too, and also as a mounted folder. + """ + # call any command to trigger a container creation + tf_container.start_shell() + container_args = tf_container.client.api.create_container.call_args[1] + + # make sure the env var is on place + assert container_args["environment"]["TF_PLUGIN_CACHE_DIR"] == "/home/testing/.terraform/cache" + + # and the cache folder mounted + assert next(m for m in container_args["host_config"]["Mounts"] if m["Target"] == "/home/testing/.terraform/cache") + + +@mock.patch("leverage.container.refresh_layer_credentials") +def test_refresh_credentials(mock_refresh, tf_container): + tf_container.enable_sso() + tf_container.refresh_credentials() + container_args = tf_container.client.api.create_container.call_args_list[0][1] + + # we want a shell, so -> /bin/bash and refresh_sso_credentials flag + assert container_args["command"] == 'echo "Done."' + mock_refresh.assert_called_once() + + +@mock.patch("leverage.container.refresh_layer_credentials") +def test_auth_method_sso_enabled(mock_refresh, tf_container): + tf_container.sso_enabled = True + tf_container.auth_method() + + mock_refresh.assert_called_once() + + +def test_auth_method_mfa_enabled(tf_container): + tf_container.sso_enabled = False + tf_container.mfa_enabled = True + + assert tf_container.auth_method() == "/home/leverage/scripts/aws-mfa/aws-mfa-entrypoint.sh -- " + + +def test_auth_method_else(tf_container): + tf_container.sso_enabled = False + tf_container.mfa_enabled = False + + assert tf_container.auth_method() == "" diff --git a/tests/test_modules/test_terraform.py b/tests/test_modules/test_tf.py similarity index 82% rename from tests/test_modules/test_terraform.py rename to tests/test_modules/test_tf.py index 79481e8..18293a2 100644 --- a/tests/test_modules/test_terraform.py +++ b/tests/test_modules/test_tf.py @@ -4,15 +4,15 @@ from click import get_current_context from leverage._internals import State -from leverage.container import TerraformContainer -from leverage.modules.terraform import _init -from leverage.modules.terraform import has_a_plan_file +from leverage.container import TFContainer +from leverage.modules.tf import _init +from leverage.modules.tf import has_a_plan_file from tests.test_containers import container_fixture_factory @pytest.fixture -def terraform_container(muted_click_context): - tf_container = container_fixture_factory(TerraformContainer) +def tf_container(muted_click_context): + tf_container = container_fixture_factory(TFContainer) # this is required because of the @pass_container decorator ctx = get_current_context() @@ -33,23 +33,23 @@ def terraform_container(muted_click_context): (["-r1", "-r2"], ["-r1", "-r2", "-backend-config=/project/./config/backend.tfvars"]), ], ) -def test_init_arguments(terraform_container, args, expected_value): +def test_init_arguments(tf_container, args, expected_value): """ Test that the arguments for the init command are prepared correctly. """ - with patch.object(terraform_container, "start_in_layer", return_value=0) as mocked: + with patch.object(tf_container, "start_in_layer", return_value=0) as mocked: _init(args) assert mocked.call_args_list[0][0][0] == "init" assert " ".join(mocked.call_args_list[0][0][1:]) == " ".join(expected_value) -def test_init_with_args(terraform_container): +def test_init_with_args(tf_container): """ Test tf init with arguments. """ # with patch("dockerpty.exec_command") as mocked_pty: - with patch.object(terraform_container, "start_in_layer", return_value=0) as mocked: + with patch.object(tf_container, "start_in_layer", return_value=0) as mocked: _init(["-migrate-state"]) assert mocked.call_args_list[0][0] == ("init", "-migrate-state", "-backend-config=/project/./config/backend.tfvars")