Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Terraform Custom Plan File Support #5734

Merged
merged 7 commits into from
Aug 10, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion samcli/commands/_utils/custom_options/hook_name_option.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@ def _call_prepare_hook(self, iac_hook_wrapper, opts):
aws_profile = opts.get("profile")
aws_region = opts.get("region")
skip_prepare_infra = opts.get("skip_prepare_infra", False)
plan_file = opts.get("terraform_plan_file")

metadata_file = iac_hook_wrapper.prepare(
output_dir_path, iac_project_path, debug, aws_profile, aws_region, skip_prepare_infra
output_dir_path, iac_project_path, debug, aws_profile, aws_region, skip_prepare_infra, plan_file
)

LOG.info("Prepare hook completed and metadata file generated at: %s", metadata_file)
Expand Down
12 changes: 12 additions & 0 deletions samcli/commands/_utils/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,18 @@ def use_container_build_option(f):
return use_container_build_click_option()(f)


def terraform_plan_file_click_option():
return click.option(
"--terraform-plan-file",
type=click.Path(),
help="Used for passing a custom plan file when executing the Terraform hook.",
)


def terraform_plan_file_option(f):
return terraform_plan_file_click_option()(f)


def build_image_click_option(cls):
return click.option(
"--build-image",
Expand Down
3 changes: 3 additions & 0 deletions samcli/commands/build/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use_container_build_option,
build_image_option,
hook_name_click_option,
terraform_plan_file_option,
)
from samcli.commands._utils.option_value_processor import process_env_var, process_image_options
from samcli.cli.main import pass_context, common_options as cli_framework_options, aws_creds_options, print_cmdline_args
Expand Down Expand Up @@ -70,6 +71,7 @@
context_settings={"max_content_width": 120},
)
@configuration_option(provider=ConfigProvider(section="parameters"))
@terraform_plan_file_option
@hook_name_click_option(
force_prepare=True,
invalid_coexist_options=["t", "template-file", "template", "parameter-overrides"],
Expand Down Expand Up @@ -155,6 +157,7 @@ def cli(
hook_name: Optional[str],
skip_prepare_infra: bool,
mount_with,
terraform_plan_file,
) -> None:
"""
`sam build` command entry point
Expand Down
4 changes: 4 additions & 0 deletions samcli/commands/build/core/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@

TEMPLATE_OPTIONS: List[str] = ["parameter_overrides"]

TERRAFORM_HOOK_OPTIONS: List[str] = ["terraform_plan_file"]

ALL_OPTIONS: List[str] = (
REQUIRED_OPTIONS
+ TEMPLATE_OPTIONS
Expand All @@ -47,6 +49,7 @@
+ EXTENSION_OPTIONS
+ CONFIGURATION_OPTION_NAMES
+ ALL_COMMON_OPTIONS
+ TERRAFORM_HOOK_OPTIONS
)

OPTIONS_INFO: Dict[str, Dict] = {
Expand All @@ -71,5 +74,6 @@
),
],
},
"Terraform Hook Options": {"option_names": {opt: {"rank": idx} for idx, opt in enumerate(TERRAFORM_HOOK_OPTIONS)}},
}
add_common_options_info(OPTIONS_INFO)
4 changes: 3 additions & 1 deletion samcli/commands/local/invoke/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from samcli.cli.main import common_options as cli_framework_options
from samcli.commands._utils.experimental import ExperimentalFlag, is_experimental_enabled
from samcli.commands._utils.option_value_processor import process_image_options
from samcli.commands._utils.options import hook_name_click_option, skip_prepare_infra_option
from samcli.commands._utils.options import hook_name_click_option, skip_prepare_infra_option, terraform_plan_file_option
from samcli.commands.local.cli_common.options import invoke_common_options, local_common_options
from samcli.commands.local.invoke.core.command import InvokeCommand
from samcli.commands.local.lib.exceptions import InvalidIntermediateImageError
Expand Down Expand Up @@ -44,6 +44,7 @@
context_settings={"max_content_width": 120},
)
@configuration_option(provider=ConfigProvider(section="parameters"))
@terraform_plan_file_option
@hook_name_click_option(
force_prepare=False, invalid_coexist_options=["t", "template-file", "template", "parameter-overrides"]
)
Expand Down Expand Up @@ -91,6 +92,7 @@ def cli(
invoke_image,
hook_name,
skip_prepare_infra,
terraform_plan_file,
):
"""
`sam local invoke` command entry point
Expand Down
4 changes: 4 additions & 0 deletions samcli/commands/local/invoke/core/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@

OTHER_OPTIONS: List[str] = ["debug"]

TERRAFORM_HOOK_OPTIONS: List[str] = ["terraform_plan_file"]

ALL_OPTIONS: List[str] = (
REQUIRED_OPTIONS
+ TEMPLATE_OPTIONS
Expand All @@ -55,6 +57,7 @@
+ EXTENSION_OPTIONS
+ CONFIGURATION_OPTION_NAMES
+ ALL_COMMON_OPTIONS
+ TERRAFORM_HOOK_OPTIONS
)

OPTIONS_INFO: Dict[str, Dict] = {
Expand All @@ -78,6 +81,7 @@
),
],
},
"Terraform Hook Options": {"option_names": {opt: {"rank": idx} for idx, opt in enumerate(TERRAFORM_HOOK_OPTIONS)}},
}

add_common_options_info(OPTIONS_INFO)
3 changes: 3 additions & 0 deletions samcli/commands/local/start_api/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
generate_next_command_recommendation,
hook_name_click_option,
skip_prepare_infra_option,
terraform_plan_file_option,
)
from samcli.commands.local.cli_common.options import (
invoke_common_options,
Expand Down Expand Up @@ -59,6 +60,7 @@
context_settings={"max_content_width": 120},
)
@configuration_option(provider=ConfigProvider(section="parameters"))
@terraform_plan_file_option
@hook_name_click_option(
force_prepare=False, invalid_coexist_options=["t", "template-file", "template", "parameter-overrides"]
)
Expand Down Expand Up @@ -109,6 +111,7 @@ def cli(
invoke_image,
hook_name,
skip_prepare_infra,
terraform_plan_file,
):
"""
`sam local start-api` command entry point
Expand Down
4 changes: 4 additions & 0 deletions samcli/commands/local/start_api/core/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
"static_dir",
]

TERRAFORM_HOOK_OPTIONS: List[str] = ["terraform_plan_file"]

ALL_OPTIONS: List[str] = (
REQUIRED_OPTIONS
+ TEMPLATE_OPTIONS
Expand All @@ -56,6 +58,7 @@
+ CONFIGURATION_OPTION_NAMES
+ ALL_COMMON_OPTIONS
+ EXTENSION_OPTIONS
+ TERRAFORM_HOOK_OPTIONS
)

OPTIONS_INFO: Dict[str, Dict] = {
Expand All @@ -79,6 +82,7 @@
),
],
},
"Terraform Hook Options": {"option_names": {opt: {"rank": idx} for idx, opt in enumerate(TERRAFORM_HOOK_OPTIONS)}},
}

add_common_options_info(OPTIONS_INFO)
3 changes: 3 additions & 0 deletions samcli/commands/local/start_lambda/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
generate_next_command_recommendation,
hook_name_click_option,
skip_prepare_infra_option,
terraform_plan_file_option,
)
from samcli.commands.local.cli_common.options import (
invoke_common_options,
Expand Down Expand Up @@ -53,6 +54,7 @@
context_settings={"max_content_width": 120},
)
@configuration_option(provider=ConfigProvider(section="parameters"))
@terraform_plan_file_option
@hook_name_click_option(
force_prepare=False, invalid_coexist_options=["t", "template-file", "template", "parameter-overrides"]
)
Expand Down Expand Up @@ -96,6 +98,7 @@ def cli(
invoke_image,
hook_name,
skip_prepare_infra,
terraform_plan_file,
):
"""
`sam local start-lambda` command entry point
Expand Down
4 changes: 4 additions & 0 deletions samcli/commands/local/start_lambda/core/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@

CONFIGURATION_OPTION_NAMES: List[str] = ["config_env", "config_file"]

TERRAFORM_HOOK_OPTIONS: List[str] = ["terraform_plan_file"]

ALL_OPTIONS: List[str] = (
REQUIRED_OPTIONS
+ TEMPLATE_OPTIONS
Expand All @@ -55,6 +57,7 @@
+ EXTENSION_OPTIONS
+ CONFIGURATION_OPTION_NAMES
+ ALL_COMMON_OPTIONS
+ TERRAFORM_HOOK_OPTIONS
)

OPTIONS_INFO: Dict[str, Dict] = {
Expand All @@ -78,6 +81,7 @@
),
],
},
"Terraform Hook Options": {"option_names": {opt: {"rank": idx} for idx, opt in enumerate(TERRAFORM_HOOK_OPTIONS)}},
}

add_common_options_info(OPTIONS_INFO)
140 changes: 85 additions & 55 deletions samcli/hook_packages/terraform/hooks/prepare/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,49 +55,23 @@ def prepare(params: dict) -> dict:
output_dir_path = os.path.normpath(os.path.join(terraform_application_dir, output_dir_path))
LOG.debug("The normalized OutputDirPath value is %s", output_dir_path)

skip_prepare_infra = params.get("SkipPrepareInfra")
skip_prepare_infra = params.get("SkipPrepareInfra", False)
metadata_file_path = os.path.join(output_dir_path, TERRAFORM_METADATA_FILE)

plan_file = params.get("PlanFile")

if skip_prepare_infra and os.path.exists(metadata_file_path):
LOG.info("Skipping preparation stage, the metadata file already exists at %s", metadata_file_path)
else:
log_msg = (
(
"The option to skip infrastructure preparation was provided, but AWS SAM CLI could not find "
f"the metadata file. Preparing anyways.{os.linesep}Initializing Terraform application"
)
if skip_prepare_infra
else "Initializing Terraform application"
)

try:
# initialize terraform application
LOG.info(log_msg)
invoke_subprocess_with_loading_pattern(
command_args={
"args": ["terraform", "init", "-input=false"],
"cwd": terraform_application_dir,
}
)

# get json output of terraform plan
LOG.info("Creating terraform plan and getting JSON output")
with osutils.tempfile_platform_independent() as temp_file:
invoke_subprocess_with_loading_pattern(
# input false to avoid SAM CLI to stuck in case if the
# Terraform project expects input, and customer does not provide it.
command_args={
"args": ["terraform", "plan", "-out", temp_file.name, "-input=false"],
"cwd": terraform_application_dir,
}
)

result = run(
["terraform", "show", "-json", temp_file.name],
check=True,
capture_output=True,
cwd=terraform_application_dir,
)
tf_json = json.loads(result.stdout)
if not plan_file:
tf_json = _generate_plan_file(skip_prepare_infra, terraform_application_dir)
else:
LOG.info(f"Using provided plan file: {plan_file}")
with open(plan_file, "r") as f:
tf_json = json.load(f)

# convert terraform to cloudformation
LOG.info("Generating metadata file")
Expand All @@ -118,26 +92,7 @@ def prepare(params: dict) -> dict:
LOG.info("Finished generating metadata file. Storing in %s", metadata_file_path)
with open(metadata_file_path, "w+") as metadata_file:
json.dump(cfn_dict, metadata_file)
except CalledProcessError as e:
stderr_output = str(e.stderr)

# stderr can take on bytes or just be a plain string depending on terminal
if isinstance(e.stderr, bytes):
stderr_output = e.stderr.decode("utf-8")

# one of the subprocess.run calls resulted in non-zero exit code or some OS error
LOG.debug(
"Error running terraform command: \n" "cmd: %s \n" "stdout: %s \n" "stderr: %s \n",
e.cmd,
e.stdout,
stderr_output,
)

raise PrepareHookException(
f"There was an error while preparing the Terraform application.\n{stderr_output}"
) from e
except LoadingPatternError as e:
raise PrepareHookException(f"Error occurred when invoking a process: {e}") from e
except OSError as e:
raise PrepareHookException(f"OSError: {e}") from e

Expand Down Expand Up @@ -167,3 +122,78 @@ def _update_resources_paths(cfn_resources: Dict[str, Any], terraform_application
original_path = resource.get("Properties", {}).get(attribute)
if isinstance(original_path, str) and not os.path.isabs(original_path):
resource["Properties"][attribute] = str(Path(terraform_application_dir).joinpath(original_path))


def _generate_plan_file(skip_prepare_infra: bool, terraform_application_dir: str) -> dict:
moelasmar marked this conversation as resolved.
Show resolved Hide resolved
"""
Call the relevant Terraform commands to generate, load and return the Terraform plan file
which the AWS SAM CLI will then parse to extract the fields required to run local emulators.

Parameters
----------
skip_prepare_infra: bool
Flag to skip skip prepare hook if we already have the metadata file. Default is False.
terraform_application_dir: str
The path where the hook can find the TF application.
Returns
-------
dict
The Terraform plan file in JSON format
"""
log_msg = (
(
"The option to skip infrastructure preparation was provided, but AWS SAM CLI could not find "
f"the metadata file. Preparing anyways.{os.linesep}Initializing Terraform application"
)
if skip_prepare_infra
else "Initializing Terraform application"
)
LOG.info(log_msg)
try:
invoke_subprocess_with_loading_pattern(
command_args={
"args": ["terraform", "init", "-input=false"],
"cwd": terraform_application_dir,
}
)

# get json output of terraform plan
LOG.info("Creating terraform plan and getting JSON output")
with osutils.tempfile_platform_independent() as temp_file:
invoke_subprocess_with_loading_pattern(
# input false to avoid SAM CLI to stuck in case if the
# Terraform project expects input, and customer does not provide it.
command_args={
"args": ["terraform", "plan", "-out", temp_file.name, "-input=false"],
"cwd": terraform_application_dir,
}
)

result = run(
["terraform", "show", "-json", temp_file.name],
check=True,
capture_output=True,
cwd=terraform_application_dir,
)
except CalledProcessError as e:
stderr_output = str(e.stderr)

# stderr can take on bytes or just be a plain string depending on terminal
if isinstance(e.stderr, bytes):
stderr_output = e.stderr.decode("utf-8")

# one of the subprocess.run calls resulted in non-zero exit code or some OS error
LOG.debug(
"Error running terraform command: \n" "cmd: %s \n" "stdout: %s \n" "stderr: %s \n",
e.cmd,
e.stdout,
stderr_output,
)

raise PrepareHookException(
f"There was an error while preparing the Terraform application.\n{stderr_output}"
) from e
except LoadingPatternError as e:
raise PrepareHookException(f"Error occurred when invoking a process: {e}") from e

return dict(json.loads(result.stdout))
Loading
Loading