diff --git a/.github/workflows/tests-unit.yml b/.github/workflows/tests-unit.yml index a76b0bca..127915e6 100644 --- a/.github/workflows/tests-unit.yml +++ b/.github/workflows/tests-unit.yml @@ -34,7 +34,7 @@ jobs: run: | echo "[INFO] Running unit tests and generate coverage report" export PATH="${PATH}:${HOME}/.poetry/bin" - poetry run pytest --verbose --cov=./ --cov-report=xml + poetry run pytest --verbose --cov=./leverage/ --cov-report=xml shell: bash - name: Report Coveralls diff --git a/Makefile b/Makefile index 6d048607..2d5aa264 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: help build LEVERAGE_TESTING_IMAGE := binbash/leverage-cli-testing LEVERAGE_TESTING_TAG := 2.5.0 -LEVERAGE_IMAGE_TAG := 1.2.7-0.0.5 +LEVERAGE_IMAGE_TAG := 1.3.5-0.2.0 PYPROJECT_FILE := pyproject.toml INIT_FILE := leverage/__init__.py PLACEHOLDER := 0.0.0 diff --git a/leverage/__init__.py b/leverage/__init__.py index 24fc9b74..ec5410bf 100644 --- a/leverage/__init__.py +++ b/leverage/__init__.py @@ -4,7 +4,7 @@ # pylint: disable=wrong-import-position __version__ = "0.0.0" -__toolbox_version__ = "1.2.7-0.1.12" +__toolbox_version__ = "1.3.5-0.2.0" import sys from shutil import which diff --git a/leverage/_utils.py b/leverage/_utils.py index c67442da..6e03e114 100644 --- a/leverage/_utils.py +++ b/leverage/_utils.py @@ -1,10 +1,6 @@ """ General use utilities. """ -import io -import os -import tarfile -from pathlib import Path from subprocess import run from subprocess import PIPE @@ -14,7 +10,6 @@ from docker.models.containers import Container from leverage import logger -from leverage.logger import raw_logger def clean_exception_traceback(exception): @@ -65,10 +60,6 @@ def git(command): run(command, stdout=PIPE, stderr=PIPE, check=True) -def chain_commands(commands: list, chain: str = " && ") -> str: - return f'bash -c "{chain.join(commands)}"' - - class CustomEntryPoint: """ Set a custom entrypoint on the container while entering the context. @@ -109,35 +100,6 @@ def __exit__(self, *args, **kwargs): "AWS_CONFIG_FILE": self.container.environment["AWS_CONFIG_FILE"].replace(".aws", "tmp"), } ) - # now return file ownership on the aws credentials files - self.container.change_file_ownership(self.container.paths.guest_aws_credentials_dir) - - -class AwsCredsContainer: - """ - Fetching AWS credentials by setting the SSO/MFA entrypoints on a living container. - This flow runs a command on a living container before any other command, leaving your AWS credentials ready - for authentication. - - In the case of MFA, the env var tweaks (that happens at .auth_method()) must stay until the end of the block - given the container is reused for more commands. - """ - - def __init__(self, container: Container, tf_container): - self.container = container - self.tf_container = tf_container - - def __enter__(self): - auth_method = self.tf_container.auth_method() - if not auth_method: - return - - exit_code, output = self.container.exec_run(auth_method, environment=self.tf_container.environment) - raw_logger.info(output.decode("utf-8")) - - def __exit__(self, *args, **kwargs): - # now return file ownership on the aws credentials files - self.tf_container.change_file_ownership(self.tf_container.paths.guest_aws_credentials_dir) class ExitError(Exit): @@ -169,40 +131,6 @@ def __exit__(self, exc_type, exc_value, exc_tb): self.docker_client.api.remove_container(self.container_data) -class LiveContainer(ContainerSession): - """ - A container that run a command that "do nothing" indefinitely. The idea is to keep the container alive. - """ - - COMMAND = "tail -f /dev/null" - - def __init__(self, leverage_container, tty=True): - with CustomEntryPoint(leverage_container, self.COMMAND): - container_data = leverage_container._create_container(tty) - super().__init__(leverage_container.client, container_data) - - -def tar_directory(host_dir_path: Path) -> bytes: - """ - Compress a local directory on memory as a tar file and return it as bytes. - """ - bytes_array = io.BytesIO() - with tarfile.open(fileobj=bytes_array, mode="w") as tar_handler: - # walk through the directory tree - for root, dirs, files in os.walk(host_dir_path): - for f in files: - # and add each file to the tar file - file_path = Path(os.path.join(root, f)) - tar_handler.add( - os.path.join(root, f), - arcname=file_path.relative_to(host_dir_path), - ) - - bytes_array.seek(0) - # return the whole tar file as a byte array - return bytes_array.read() - - def key_finder(d: dict, target: str, avoid: str = None): """ Iterate over a dict of dicts and/or lists of dicts, looking for a key with value "target". diff --git a/leverage/container.py b/leverage/container.py index 3ac73e37..a8258ecb 100644 --- a/leverage/container.py +++ b/leverage/container.py @@ -2,7 +2,7 @@ import os import re import webbrowser -from pathlib import Path +from io import BytesIO from datetime import datetime from time import sleep @@ -11,15 +11,14 @@ import dockerpty from configupdater import ConfigUpdater from docker import DockerClient -from docker.errors import APIError, NotFound +from docker.errors import APIError from docker.types import Mount -from typing import Tuple, Union +from typing import Tuple -from leverage import __toolbox_version__ from leverage import logger from leverage._utils import AwsCredsEntryPoint, CustomEntryPoint, ExitError, ContainerSession from leverage.modules.auth import refresh_layer_credentials -from leverage.logger import console, raw_logger +from leverage.logger import raw_logger from leverage.logger import get_script_log_level from leverage.path import PathsHandler from leverage.conf import load as load_env @@ -67,6 +66,7 @@ class LeverageContainer: LEVERAGE_IMAGE = "binbash/leverage-toolbox" SHELL = "/bin/bash" + CONTAINER_USER = "leverage" def __init__(self, client, mounts: tuple = None, env_vars: dict = None): """Project related paths are determined and stored. Project configuration is loaded. @@ -78,7 +78,7 @@ def __init__(self, client, mounts: tuple = None, env_vars: dict = None): # Load configs self.env_conf = load_env() - self.paths = PathsHandler(self.env_conf) + self.paths = PathsHandler(self.env_conf, self.CONTAINER_USER) self.project = self.paths.project # Set image to use @@ -94,7 +94,7 @@ def __init__(self, client, mounts: tuple = None, env_vars: dict = None): mounts = [Mount(source=source, target=target, type="bind") for source, target in mounts] if mounts else [] self.host_config = self.client.api.create_host_config(security_opt=["label=disable"], mounts=mounts) self.container_config = { - "image": f"{self.image}:{self.image_tag}", + "image": f"{self.image}:{self.local_image_tag}", "command": "", "stdin_open": True, "environment": env_vars or {}, @@ -138,43 +138,65 @@ def region(self): raise ExitError(1, f"No valid region could be found at: {self.paths.cwd.as_posix()}") + @property + def local_image_tag(self): + return f"{self.image_tag}-{os.getgid()}-{os.getuid()}" + + @property + def local_image(self) -> BytesIO: + """Return the local image that will be built, as a file-like object.""" + return BytesIO( + """ + ARG IMAGE_TAG + FROM binbash/leverage-toolbox:$IMAGE_TAG + + ARG UNAME + ARG UID + ARG GID + RUN groupadd -g $GID -o $UNAME + RUN useradd -m -u $UID -g $GID -o -s /bin/bash $UNAME + RUN chown -R $UID:$GID /home/leverage + USER $UNAME + """.encode( + "utf-8" + ) + ) + def ensure_image(self): - """Make sure the required Docker image is available in the system. If not, pull it from registry.""" - found_image = self.client.api.images(f"{self.image}:{self.image_tag}") + """ + Make sure the required local Docker image is available in the system. If not, build it. + If the image already exists, re-build it so changes in the arguments can take effect. + """ + logger.info(f"Checking for local docker image, tag: {self.local_image_tag}...") + image_name = f"{self.image}:{self.local_image_tag}" + + # check first is our image is already available locally + found_image = self.client.api.images(f"{self.image}:{self.local_image_tag}") if found_image: + logger.info("[green]✔ OK[/green]\n") return - logger.info("Required Docker image not found.") - - try: - stream = self.client.api.pull(repository=self.image, tag=self.image_tag, stream=True, decode=True) - except NotFound as e: - logger.error( - f"The specified toolbox version, '{self.image_tag}' (toolbox image '{self.image}:{self.image_tag}') can not be found. " - "If you come from a project created with an older version of Leverage CLI or have modified the 'build.env' file manually, " - f"please consider either deleting the file, or configuring a valid toolbox version to use. (i.e. 'TERRAFORM_IMAGE_TAG={__toolbox_version__}')" - ) - raise Exit(1) - except APIError as pull: - pull.__traceback__ = None - pull.__context__.__traceback__ = None - logger.exception("Error pulling image:", exc_info=pull) - raise Exit(1) - except Exception as e: - logger.error(f"Not handled error while pulling the image: {e}") - raise Exit(1) - - logger.info(next(stream)["status"]) + logger.info(f"Image not found, building it...") + build_args = { + "IMAGE_TAG": self.image_tag, + "UNAME": self.CONTAINER_USER, + "GID": str(os.getgid()), + "UID": str(os.getuid()), + } - imageinfo = [] - with console.status("Pulling image..."): - for status in stream: - status = status["status"] - if status.startswith("Digest") or status.startswith("Status"): - imageinfo.append(status) + stream = self.client.api.build( + fileobj=self.local_image, + tag=image_name, + pull=True, + buildargs=build_args, + decode=True, + ) - for info in imageinfo: - logger.info(info) + for line in stream: + if "stream" in line and line["stream"].startswith("Successfully built"): + logger.info("[green]✔ OK[/green]\n") + elif "errorDetail" in line: + raise ExitError(1, f"Failed building local image: {line['errorDetail']}") def _create_container(self, tty, command="", *args): """Create the container that will run the command. @@ -285,24 +307,18 @@ def exec(self, command: str, *arguments) -> Tuple[int, str]: def docker_logs(self, container): return self.client.api.logs(container).decode("utf-8") - def change_ownership_cmd(self, path: Union[Path, str], recursive=True) -> str: - recursive = "-R " if recursive else "" - user_id = os.getuid() - group_id = os.getgid() - return f"chown {user_id}:{group_id} {recursive}{path}" - - def change_file_ownership(self, path: Union[Path, str], recursive=True): - """ - Change the file/folder ownership from the internal docker user (usually root) - to the user executing the CLI. - """ - cmd = self.change_ownership_cmd(path, recursive=recursive) - with CustomEntryPoint(self, entrypoint=""): - self._exec(cmd) +class SSOContainer(LeverageContainer): + # SSO scripts + AWS_SSO_LOGIN_SCRIPT = "/home/leverage/scripts/aws-sso/aws-sso-login.sh" + AWS_SSO_LOGOUT_SCRIPT = "/home/leverage/scripts/aws-sso/aws-sso-logout.sh" + # SSO constants + AWS_SSO_LOGIN_URL = "https://device.sso.{region}.amazonaws.com/?user_code={user_code}" + AWS_SSO_CODE_WAIT_SECONDS = 2 + AWS_SSO_CODE_ATTEMPTS = 10 + FALLBACK_LINK_MSG = "Opening the browser... if it fails, open this link in your browser:\n{link}" -class SSOContainer(LeverageContainer): def get_sso_access_token(self): with open(self.paths.sso_token_file) as token_file: return json.loads(token_file.read())["accessToken"] @@ -316,22 +332,57 @@ def sso_region_from_main_profile(self): conf.read(self.paths.host_aws_profiles_file) return conf.get(f"profile {self.project}-sso", "sso_region").value + def get_sso_code(self, container) -> str: + """ + Find and return the SSO user code by periodically checking the logs. + Up until N attempts. + """ + logger.info("Fetching SSO code...") + for _ in range(self.AWS_SSO_CODE_ATTEMPTS): + # pull logs periodically until we find our SSO code + logs = self.docker_logs(container) + if "Then enter the code:" in logs: + return logs.split("Then enter the code:")[1].split("\n")[2] + else: + logger.debug(logs) + sleep(self.AWS_SSO_CODE_WAIT_SECONDS) + + raise ExitError(1, "Get SSO code timed-out") + + def get_sso_region(self): + # TODO: what about using the .region property we have now? that takes the value from the path of the layer + _, region = self.exec(f"configure get sso_region --profile {self.project}-sso") + return region + + def sso_login(self) -> int: + region = self.get_sso_region() + + with CustomEntryPoint(self, "sh -c"): + container = self._create_container(False, command=self.AWS_SSO_LOGIN_SCRIPT) + + with ContainerSession(self.client, container): + # once inside this block, the SSO_LOGIN_SCRIPT is being executed in the "background" + # now let's grab the user code from the logs + user_code = self.get_sso_code(container) + # with the user code, we can now autocomplete the url + link = self.AWS_SSO_LOGIN_URL.format(region=region.strip(), user_code=user_code) + webbrowser.open_new_tab(link) + # The SSO code is only valid once: if the browser was able to open it, the fallback link will be invalid + logger.info(self.FALLBACK_LINK_MSG.format(link=link)) + # now let's wait until the command locking the container resolve itself: + # aws sso login will wait for the user code + # once submitted to the browser, the authentication finish and the lock is released + exit_code = self.client.api.wait(container)["StatusCode"] + raw_logger.info(self.docker_logs(container)) + + return exit_code + class AWSCLIContainer(SSOContainer): """Leverage Container specially tailored to run AWS CLI commands.""" AWS_CLI_BINARY = "/usr/local/bin/aws" - # SSO scripts - AWS_SSO_LOGIN_SCRIPT = "/root/scripts/aws-sso/aws-sso-login.sh" - AWS_SSO_LOGOUT_SCRIPT = "/root/scripts/aws-sso/aws-sso-logout.sh" - - # SSO constants - AWS_SSO_LOGIN_URL = "https://device.sso.{region}.amazonaws.com/?user_code={user_code}" - AWS_SSO_CODE_WAIT_SECONDS = 2 - AWS_SSO_CODE_ATTEMPTS = 10 - FALLBACK_LINK_MSG = "Opening the browser... if it fails, open this link in your browser:\n{link}" - def __init__(self, client): super().__init__(client) @@ -381,52 +432,6 @@ def system_exec(self, command): self.entrypoint = self.AWS_CLI_BINARY return exit_code, output - def get_sso_code(self, container) -> str: - """ - Find and return the SSO user code by periodically checking the logs. - Up until N attempts. - """ - logger.info("Fetching SSO code...") - for _ in range(self.AWS_SSO_CODE_ATTEMPTS): - # pull logs periodically until we find our SSO code - logs = self.docker_logs(container) - if "Then enter the code:" in logs: - return logs.split("Then enter the code:")[1].split("\n")[2] - - sleep(self.AWS_SSO_CODE_WAIT_SECONDS) - - raise ExitError(1, "Get SSO code timed-out") - - def get_sso_region(self): - # TODO: what about using the .region property we have now? that takes the value from the path of the layer - _, region = self.exec(f"configure get sso_region --profile {self.project}-sso") - return region - - def sso_login(self) -> int: - region = self.get_sso_region() - - with CustomEntryPoint(self, "sh -c"): - container = self._create_container(False, command=self.AWS_SSO_LOGIN_SCRIPT) - - with ContainerSession(self.client, container): - # once inside this block, the SSO_LOGIN_SCRIPT is being executed in the "background" - # now let's grab the user code from the logs - user_code = self.get_sso_code(container) - # with the user code, we can now autocomplete the url - link = self.AWS_SSO_LOGIN_URL.format(region=region.strip(), user_code=user_code) - webbrowser.open_new_tab(link) - # The SSO code is only valid once: if the browser was able to open it, the fallback link will be invalid - logger.info(self.FALLBACK_LINK_MSG.format(link=link)) - # now let's wait until the command locking the container resolve itself: - # aws sso login will wait for the user code - # once submitted to the browser, the authentication finish and the lock is released - exit_code = self.client.api.wait(container)["StatusCode"] - raw_logger.info(self.docker_logs(container)) - # now return ownership of the token file back to the user - self.change_file_ownership(self.paths.guest_aws_credentials_dir) - - return exit_code - class TerraformContainer(SSOContainer): """Leverage container specifically tailored to run Terraform commands. @@ -434,8 +439,7 @@ class TerraformContainer(SSOContainer): TF_BINARY = "/bin/terraform" - TF_MFA_ENTRYPOINT = "/root/scripts/aws-mfa/aws-mfa-entrypoint.sh" - TF_SSO_ENTRYPOINT = "/root/scripts/aws-sso/aws-sso-entrypoint.sh" + TF_MFA_ENTRYPOINT = "/home/leverage/scripts/aws-mfa/aws-mfa-entrypoint.sh" def __init__(self, client, mounts=None, env_vars=None): super().__init__(client, mounts=mounts, env_vars=env_vars) diff --git a/leverage/containers/kubectl.py b/leverage/containers/kubectl.py index c8d62c74..d2cb2dc2 100644 --- a/leverage/containers/kubectl.py +++ b/leverage/containers/kubectl.py @@ -4,7 +4,7 @@ from docker.types import Mount from leverage import logger -from leverage._utils import chain_commands, AwsCredsEntryPoint, ExitError +from leverage._utils import AwsCredsEntryPoint, ExitError from leverage.container import TerraformContainer @@ -12,7 +12,7 @@ class KubeCtlContainer(TerraformContainer): """Container specifically tailored to run kubectl commands.""" KUBECTL_CLI_BINARY = "/usr/local/bin/kubectl" - KUBECTL_CONFIG_PATH = Path("/root/.kube") + KUBECTL_CONFIG_PATH = Path(f"/home/{TerraformContainer.CONTAINER_USER}/.kube") KUBECTL_CONFIG_FILE = KUBECTL_CONFIG_PATH / Path("config") def __init__(self, client): @@ -46,13 +46,10 @@ def configure(self): # generate the command that will configure the new cluster with AwsCredsEntryPoint(self, override_entrypoint=""): add_eks_cluster_cmd = self._get_eks_kube_config() - # and the command that will set the proper ownership on the config file (otherwise the owner will be "root") - change_owner_cmd = self.change_ownership_cmd(self.KUBECTL_CONFIG_FILE, recursive=False) - full_cmd = chain_commands([add_eks_cluster_cmd, change_owner_cmd]) logger.info("Configuring context...") with AwsCredsEntryPoint(self, override_entrypoint=""): - exit_code = self._start(full_cmd) + exit_code = self._start(add_eks_cluster_cmd) if exit_code: raise Exit(exit_code) diff --git a/leverage/modules/terraform.py b/leverage/modules/terraform.py index 357ae085..d7639722 100644 --- a/leverage/modules/terraform.py +++ b/leverage/modules/terraform.py @@ -1,16 +1,13 @@ -import os import re from typing import Sequence import click -import dockerpty import hcl2 from click.exceptions import Exit from leverage import logger -from leverage._internals import pass_container -from leverage._internals import pass_state -from leverage._utils import tar_directory, AwsCredsContainer, LiveContainer, ExitError +from leverage._internals import pass_container, pass_state +from leverage._utils import ExitError from leverage.container import TerraformContainer from leverage.container import get_docker_client from leverage.modules.utils import env_var_option, mount_option, auth_mfa, auth_sso @@ -85,14 +82,7 @@ def init(context, tf: TerraformContainer, skip_validation, layers, args): """ Initialize this layer. """ - layers = invoke_for_all_commands(layers, _init, args, skip_validation) - - # now change ownership on all the downloaded modules and providers - for layer in layers: - tf.change_file_ownership(tf.paths.guest_base_path / layer.relative_to(tf.paths.root_dir) / ".terraform") - # and then providers in the cache folder - if tf.paths.tf_cache_dir: - tf.change_file_ownership(tf.paths.tf_cache_dir) + invoke_for_all_commands(layers, _init, args, skip_validation) @terraform.command(context_settings=CONTEXT_SETTINGS) @@ -324,23 +314,9 @@ def _init(tf, args): tf.paths.check_for_layer_location() - with LiveContainer(tf) as container: - # create the .ssh directory - container.exec_run("mkdir -p /root/.ssh") - # copy the entire ~/.ssh/ folder - tar_bytes = tar_directory(tf.paths.home / ".ssh") - # into /root/.ssh - container.put_archive("/root/.ssh/", tar_bytes) - # correct the owner of the files to match with the docker internal user - container.exec_run("chown root:root -R /root/.ssh/") - - with AwsCredsContainer(container, tf): - dockerpty.exec_command( - client=tf.client.api, - container=container.id, - command="terraform init " + " ".join(args), - interactive=bool(int(os.environ.get("LEVERAGE_INTERACTIVE", 1))), - ) + exit_code = tf.start_in_layer("init", *args) + if exit_code: + raise Exit(exit_code) @pass_container diff --git a/leverage/path.py b/leverage/path.py index 9a53852f..1a09d244 100644 --- a/leverage/path.py +++ b/leverage/path.py @@ -134,7 +134,8 @@ class PathsHandler: ACCOUNT_TF_VARS = "account.tfvars" BACKEND_TF_VARS = "backend.tfvars" - def __init__(self, env_conf): + def __init__(self, env_conf: dict, container_user: str): + self.container_user = container_user self.home = Path.home() self.cwd = Path.cwd() try: @@ -186,7 +187,7 @@ def backend_tfvars(self): @property def guest_aws_credentials_dir(self): - return f"/root/tmp/{self.project}" + return str(f"/home/{self.container_user}/tmp" / Path(self.project)) @property def host_aws_profiles_file(self): diff --git a/tests/test_containers/test_aws.py b/tests/test_containers/test_aws.py index 4532109d..819981a7 100644 --- a/tests/test_containers/test_aws.py +++ b/tests/test_containers/test_aws.py @@ -55,12 +55,8 @@ def test_sso_login(mocked_new_tab, aws_container, fake_os_user, propagate_logs, container_args = aws_container.client.api.create_container.call_args_list[0][1] # make sure we: point to the correct script - assert container_args["command"] == "/root/scripts/aws-sso/aws-sso-login.sh" + assert container_args["command"] == "/home/leverage/scripts/aws-sso/aws-sso-login.sh" # the browser tab points to the correct code and the correct region assert mocked_new_tab.call_args[0][0] == "https://device.sso.us-east-1.amazonaws.com/?user_code=TEST-CODE" - # ownership of the files was given back - container_args = aws_container.client.api.create_container.call_args_list[1][1] - assert container_args["command"] == "chown 1234:5678 -R /root/tmp/test" - assert mocked_new_tab.call_args[0][0] == test_link # and the fallback method is printed assert caplog.messages[0] == aws_container.FALLBACK_LINK_MSG.format(link=test_link) diff --git a/tests/test_containers/test_kubectl.py b/tests/test_containers/test_kubectl.py index 023bdba5..da67bb20 100644 --- a/tests/test_containers/test_kubectl.py +++ b/tests/test_containers/test_kubectl.py @@ -57,14 +57,15 @@ def test_start_shell(kubectl_container): assert container_args["entrypoint"] == "" # make sure we are pointing to the AWS credentials - assert container_args["environment"]["AWS_CONFIG_FILE"] == "/root/tmp/test/config" - assert container_args["environment"]["AWS_SHARED_CREDENTIALS_FILE"] == "/root/tmp/test/credentials" + assert container_args["environment"]["AWS_CONFIG_FILE"] == "/home/leverage/tmp/test/config" + assert container_args["environment"]["AWS_SHARED_CREDENTIALS_FILE"] == "/home/leverage/tmp/test/credentials" # make sure we mounted the .kube config folder - assert next(m for m in container_args["host_config"]["Mounts"] if m["Target"] == "/root/.kube") + print(container_args["host_config"]) + assert next(m for m in container_args["host_config"]["Mounts"] if m["Target"] == "/home/leverage/.kube") # and the aws config folder - assert next(m for m in container_args["host_config"]["Mounts"] if m["Target"] == "/root/tmp/test") + assert next(m for m in container_args["host_config"]["Mounts"] if m["Target"] == "/home/leverage/tmp/test") # don't rely on the filesystem @@ -75,7 +76,7 @@ def test_configure(kubectl_container, fake_os_user): with patch.object(kubectl_container, "_start", return_value=0) as mock_start: kubectl_container.configure() - assert mock_start.call_args[0][0] == f'bash -c "{AWS_EKS_UPDATE_KUBECONFIG} && chown 1234:5678 /root/.kube/config"' + assert mock_start.call_args[0][0] == AWS_EKS_UPDATE_KUBECONFIG ##################### @@ -96,11 +97,11 @@ def test_start_shell_mfa(kubectl_container): # we want a shell, so -> /bin/bash with no entrypoint assert container_args["command"] == "/bin/bash" - assert container_args["entrypoint"] == "/root/scripts/aws-mfa/aws-mfa-entrypoint.sh -- " + assert container_args["entrypoint"] == "/home/leverage/scripts/aws-mfa/aws-mfa-entrypoint.sh -- " # make sure we are pointing to the right AWS credentials: /.aws/ folder for MFA - assert container_args["environment"]["AWS_CONFIG_FILE"] == "/root/.aws/test/config" - assert container_args["environment"]["AWS_SHARED_CREDENTIALS_FILE"] == "/root/.aws/test/credentials" + assert container_args["environment"]["AWS_CONFIG_FILE"] == "/home/leverage/.aws/test/config" + assert container_args["environment"]["AWS_SHARED_CREDENTIALS_FILE"] == "/home/leverage/.aws/test/credentials" @patch("leverage.container.refresh_layer_credentials") @@ -118,5 +119,5 @@ def test_start_shell_sso(mock_refresh, kubectl_container): assert mock_refresh.assert_called_once # make sure we are pointing to the right AWS credentials: /tmp/ folder for SSO - assert container_args["environment"]["AWS_CONFIG_FILE"] == "/root/tmp/test/config" - assert container_args["environment"]["AWS_SHARED_CREDENTIALS_FILE"] == "/root/tmp/test/credentials" + assert container_args["environment"]["AWS_CONFIG_FILE"] == "/home/leverage/tmp/test/config" + assert container_args["environment"]["AWS_SHARED_CREDENTIALS_FILE"] == "/home/leverage/tmp/test/credentials" diff --git a/tests/test_containers/test_leverage.py b/tests/test_containers/test_leverage.py index 01596a3a..5ccf74f6 100644 --- a/tests/test_containers/test_leverage.py +++ b/tests/test_containers/test_leverage.py @@ -1,5 +1,8 @@ +from unittest import mock + import pytest +from leverage._utils import ExitError from leverage.container import LeverageContainer from tests.test_containers import container_fixture_factory @@ -9,23 +12,6 @@ def leverage_container(muted_click_context): return container_fixture_factory(LeverageContainer) -def test_change_ownership_cmd(leverage_container, fake_os_user): - assert leverage_container.change_ownership_cmd("/tmp/", recursive=True) == "chown 1234:5678 -R /tmp/" - - -def test_change_ownership_non_recursive_cmd(leverage_container, fake_os_user): - assert leverage_container.change_ownership_cmd("/tmp/file.txt", recursive=False) == "chown 1234:5678 /tmp/file.txt" - - -def test_change_file_ownership(leverage_container, fake_os_user): - leverage_container.change_file_ownership("/tmp/file.txt", recursive=False) - container_args = leverage_container.client.api.create_container.call_args_list[0][1] - - assert container_args["command"] == "chown 1234:5678 /tmp/file.txt" - # we use chown directly so no entrypoint must be set - assert container_args["entrypoint"] == "" - - def test_mounts(muted_click_context): container = container_fixture_factory( LeverageContainer, mounts=(("/usr/bin", "/usr/bin"), ("/tmp/file.txt", "/tmp/file.txt")) @@ -43,3 +29,51 @@ def test_env_vars(muted_click_context): container_args = container.client.api.create_container.call_args_list[0][1] assert container_args["environment"] == {"foo": "bar", "testing": 123} + + +def test_ensure_image_already_available(leverage_container: LeverageContainer, fake_os_user, propagate_logs, caplog): + """ + Test that the local image is not re-built when is already available locally. + """ + # already available + with mock.patch.object(leverage_container.client.api, "images", return_value=True) as mocked_images: + leverage_container.ensure_image() + + assert mocked_images.call_args_list[0][0][0] == "binbash/leverage-toolbox:test-5678-1234" + assert caplog.messages[0] == "Checking for local docker image, tag: test-5678-1234..." + assert "OK" in caplog.messages[1] + + +def test_ensure_image_failed(leverage_container: LeverageContainer, fake_os_user, propagate_logs, caplog): + """ + Test that we get a friendly error if re-building the image fails. + """ + build_response = [{"errorDetail": "Something went wrong"}] + # not available + with mock.patch.object(leverage_container.client.api, "images", return_value=False): + with mock.patch.object(leverage_container.client.api, "build", return_value=build_response) as mocked_build: + with pytest.raises(ExitError, match="Failed"): + leverage_container.ensure_image() + + assert caplog.messages[1] == "Image not found, building it..." + assert caplog.messages[2] == "Failed building local image: Something went wrong" + + +def test_ensure_image(leverage_container: LeverageContainer, fake_os_user, propagate_logs, caplog): + """ + Test that the local image is not available locally, thus it has to be re-built. + """ + build_response = [{"stream": "Successfully built"}] + # not available + with mock.patch.object(leverage_container.client.api, "images", return_value=False): + with mock.patch.object(leverage_container.client.api, "build", return_value=build_response) as mocked_build: + leverage_container.ensure_image() + + assert mocked_build.call_args_list[0][1]["buildargs"] == { + "GID": "5678", + "UID": "1234", + "UNAME": "leverage", + "IMAGE_TAG": "test", + } + assert caplog.messages[1] == "Image not found, building it..." + assert "OK" in caplog.messages[2] diff --git a/tests/test_containers/test_terraform.py b/tests/test_containers/test_terraform.py index 9342d7fa..76ab7039 100644 --- a/tests/test_containers/test_terraform.py +++ b/tests/test_containers/test_terraform.py @@ -51,7 +51,7 @@ def test_auth_method_mfa_enabled(terraform_container): terraform_container.sso_enabled = False terraform_container.mfa_enabled = True - assert terraform_container.auth_method() == "/root/scripts/aws-mfa/aws-mfa-entrypoint.sh -- " + assert terraform_container.auth_method() == "/home/leverage/scripts/aws-mfa/aws-mfa-entrypoint.sh -- " def test_auth_method_else(terraform_container): diff --git a/tests/test_modules/test_terraform.py b/tests/test_modules/test_terraform.py index 2ccc5fb7..79481e82 100644 --- a/tests/test_modules/test_terraform.py +++ b/tests/test_modules/test_terraform.py @@ -4,7 +4,6 @@ from click import get_current_context from leverage._internals import State -from leverage._utils import AwsCredsContainer from leverage.container import TerraformContainer from leverage.modules.terraform import _init from leverage.modules.terraform import has_a_plan_file @@ -23,39 +22,37 @@ def terraform_container(muted_click_context): # assume we are on a valid location with patch.object(tf_container.paths, "check_for_layer_location", Mock()): - # assume we have valid credentials - with patch.object(AwsCredsContainer, "__enter__", Mock()): - yield tf_container + yield tf_container -def test_init(terraform_container): +@pytest.mark.parametrize( + "args, expected_value", + [ + ([], ["-backend-config=/project/./config/backend.tfvars"]), + (["-migrate-state"], ["-migrate-state", "-backend-config=/project/./config/backend.tfvars"]), + (["-r1", "-r2"], ["-r1", "-r2", "-backend-config=/project/./config/backend.tfvars"]), + ], +) +def test_init_arguments(terraform_container, args, expected_value): """ - Test happy path. + Test that the arguments for the init command are prepared correctly. """ - live_container = Mock() - with patch("leverage._utils.LiveContainer.__enter__", return_value=live_container): - with patch("dockerpty.exec_command") as mocked_pty: - _init([]) + with patch.object(terraform_container, "start_in_layer", return_value=0) as mocked: + _init(args) - assert live_container.exec_run.call_args_list[0].args[0] == "mkdir -p /root/.ssh" - assert live_container.exec_run.call_args_list[1].args[0] == "chown root:root -R /root/.ssh/" - assert ( - mocked_pty.call_args_list[0].kwargs["command"] - == f"terraform init -backend-config=/project/./config/backend.tfvars" - ) + 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): """ Test tf init with arguments. """ - with patch("dockerpty.exec_command") as mocked_pty: + # with patch("dockerpty.exec_command") as mocked_pty: + with patch.object(terraform_container, "start_in_layer", return_value=0) as mocked: _init(["-migrate-state"]) - assert ( - mocked_pty.call_args_list[0].kwargs["command"] - == f"terraform init -migrate-state -backend-config=/project/./config/backend.tfvars" - ) + assert mocked.call_args_list[0][0] == ("init", "-migrate-state", "-backend-config=/project/./config/backend.tfvars") @pytest.mark.parametrize( diff --git a/tests/test_path.py b/tests/test_path.py index 2aa7796b..d52f0d60 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -113,7 +113,7 @@ def test_check_for_cluster_layer(muted_click_context, propagate_logs, caplog): """ Test that if we are not on a cluster layer, we raise an error. """ - paths = PathsHandler({"PROJECT": "test"}) + paths = PathsHandler({"PROJECT": "test"}, "leverage") with patch.object(paths, "check_for_layer_location"): # assume parent method is already tested with pytest.raises(ExitError): paths.cwd = Path("/random")