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

chore: Resolve conflicts with upstream/develop #5830

Merged
merged 13 commits into from
Aug 23, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
22 changes: 22 additions & 0 deletions samcli/commands/_utils/custom_options/hook_name_option.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@

import logging
import os
from typing import Any, Mapping

import click

from samcli.cli.context import Context
from samcli.commands._utils.constants import DEFAULT_BUILT_TEMPLATE_PATH
from samcli.lib.hook.exceptions import InvalidHookWrapperException
from samcli.lib.hook.hook_wrapper import IacHookWrapper, get_available_hook_packages_ids
from samcli.lib.telemetry.event import EventName, EventTracker

LOG = logging.getLogger(__name__)

PLAN_FILE_OPTION = "terraform_plan_file"


class HookNameOption(click.Option):
"""
Expand Down Expand Up @@ -56,6 +60,8 @@ def handle_parse_result(self, ctx, opts, args):
# capture exceptions from prepare hook to emit in track_command
c = Context.get_current_context()
c.exception = ex
finally:
record_hook_telemetry(opts, ctx)

return super().handle_parse_result(ctx, opts, args)

Expand Down Expand Up @@ -132,3 +138,19 @@ def _read_parameter_value(param_name, opts, ctx, default_value=None):
Read SAM CLI parameter value either from the parameters list or from the samconfig values
"""
return opts.get(param_name, ctx.default_map.get(param_name, default_value))


def record_hook_telemetry(opts: Mapping[str, Any], ctx: click.Context):
"""
Emit metrics related to hooks based on the options passed into the command

Parameters
----------
opts: Mapping[str, Any]
Mapping between a command line option and its value
ctx: Context
Command context properties
"""
plan_file_param = _read_parameter_value(PLAN_FILE_OPTION, opts, ctx)
if plan_file_param:
EventTracker.track_event(EventName.HOOK_CONFIGURATIONS_USED.value, "TerraformPlanFile")
19 changes: 16 additions & 3 deletions samcli/commands/remote/invoke/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ def do_cli(
"""
Implementation of the ``cli`` method
"""
from botocore.exceptions import (
NoCredentialsError,
NoRegionError,
ProfileNotFound,
)

from samcli.commands.exceptions import UserException
from samcli.commands.remote.remote_invoke_context import RemoteInvokeContext
from samcli.lib.remote_invoke.exceptions import (
Expand All @@ -127,9 +133,9 @@ def do_cli(
from samcli.lib.remote_invoke.remote_invoke_executors import RemoteInvokeExecutionInfo
from samcli.lib.utils.boto_utils import get_boto_client_provider_with_config, get_boto_resource_provider_with_config

boto_client_provider = get_boto_client_provider_with_config(region_name=region)
boto_resource_provider = get_boto_resource_provider_with_config(region_name=region)
try:
boto_client_provider = get_boto_client_provider_with_config(region_name=region, profile=profile)
boto_resource_provider = get_boto_resource_provider_with_config(region_name=region, profile=profile)
with RemoteInvokeContext(
boto_client_provider=boto_client_provider,
boto_resource_provider=boto_resource_provider,
Expand All @@ -142,5 +148,12 @@ def do_cli(
)

remote_invoke_context.run(remote_invoke_input=remote_invoke_input)
except (ErrorBotoApiCallException, InvalideBotoResponseException, InvalidResourceBotoParameterException) as ex:
except (
ErrorBotoApiCallException,
InvalideBotoResponseException,
InvalidResourceBotoParameterException,
ProfileNotFound,
NoCredentialsError,
NoRegionError,
) as ex:
raise UserException(str(ex), wrapped_from=ex.__class__.__name__) from ex
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@
LOG = logging.getLogger(__name__)

TF_BACKEND_OVERRIDE_FILENAME = "z_samcli_backend_override"
TF_BLOCKED_ARGUMENTS = [
"-target",
"-destroy",
]
TF_ENVIRONMENT_VARIABLE_DELIM = "="
TF_ENVIRONMENT_VARIABLES = [
"TF_CLI_ARGS",
"TF_CLI_ARGS_plan",
"TF_CLI_ARGS_apply",
]


class ResolverException(Exception):
Expand Down Expand Up @@ -276,6 +286,31 @@ def create_backend_override():
cli_exit()


def validate_environment_variables():
"""
Validate that the Terraform environment variables do not contain blocked arguments.
"""
for env_var in TF_ENVIRONMENT_VARIABLES:
env_value = os.environ.get(env_var, "")

trimmed_arguments = []
# get all trimmed arguments in a list and split on delim
# eg.
# "-foo=bar -hello" => ["-foo", "-hello"]
for argument in env_value.split(" "):
cleaned_argument = argument.strip()
cleaned_argument = cleaned_argument.split(TF_ENVIRONMENT_VARIABLE_DELIM)[0]

trimmed_arguments.append(cleaned_argument)

if any([argument in TF_BLOCKED_ARGUMENTS for argument in trimmed_arguments]):
LOG.error(
"Environment variable '%s' contains "
"a blocked argument, please validate it does not contain: %s" % (env_var, TF_BLOCKED_ARGUMENTS)
)
cli_exit()


if __name__ == "__main__":
# Gather inputs and clean them
argparser = argparse.ArgumentParser(
Expand Down Expand Up @@ -314,6 +349,9 @@ def create_backend_override():
target = arguments.target
json_str = arguments.json

# validate environment variables do not contain blocked arguments
validate_environment_variables()

if target and json_str:
LOG.error("Provide either --target or --json. Do not provide both.")
cli_exit()
Expand Down
52 changes: 51 additions & 1 deletion samcli/hook_packages/terraform/hooks/prepare/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@

from samcli.hook_packages.terraform.hooks.prepare.constants import CFN_CODE_PROPERTIES
from samcli.hook_packages.terraform.hooks.prepare.translate import translate_to_cfn
from samcli.lib.hook.exceptions import PrepareHookException, TerraformCloudException
from samcli.lib.hook.exceptions import (
PrepareHookException,
TerraformCloudException,
UnallowedEnvironmentVariableArgumentException,
)
from samcli.lib.utils import osutils
from samcli.lib.utils.subprocess_utils import LoadingPatternError, invoke_subprocess_with_loading_pattern

Expand All @@ -32,6 +36,17 @@
"a plan file using the --terraform-plan-file flag."
)

TF_BLOCKED_ARGUMENTS = [
"-target",
"-destroy",
]
TF_ENVIRONMENT_VARIABLE_DELIM = "="
TF_ENVIRONMENT_VARIABLES = [
"TF_CLI_ARGS",
"TF_CLI_ARGS_plan",
"TF_CLI_ARGS_apply",
]


def prepare(params: dict) -> dict:
"""
Expand All @@ -55,6 +70,8 @@ def prepare(params: dict) -> dict:
if not output_dir_path:
raise PrepareHookException("OutputDirPath was not supplied")

_validate_environment_variables()

LOG.debug("Normalize the terraform application root module directory path %s", terraform_application_dir)
if not os.path.isabs(terraform_application_dir):
terraform_application_dir = os.path.normpath(os.path.join(os.getcwd(), terraform_application_dir))
Expand Down Expand Up @@ -215,3 +232,36 @@ def _generate_plan_file(skip_prepare_infra: bool, terraform_application_dir: str
raise PrepareHookException(f"Error occurred when invoking a process:\n{e}") from e

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


def _validate_environment_variables() -> None:
"""
Validate that the Terraform environment variables do not contain blocked arguments.

Raises
------
UnallowedEnvironmentVariableArgumentException
Raised when a Terraform related environment variable contains a blocked value
"""
for env_var in TF_ENVIRONMENT_VARIABLES:
env_value = os.environ.get(env_var, "")

trimmed_arguments = []
# get all trimmed arguments in a list and split on delim
# eg.
# "-foo=bar -hello" => ["-foo", "-hello"]
for argument in env_value.split(" "):
cleaned_argument = argument.strip()
cleaned_argument = cleaned_argument.split(TF_ENVIRONMENT_VARIABLE_DELIM)[0]

trimmed_arguments.append(cleaned_argument)

if any([argument in TF_BLOCKED_ARGUMENTS for argument in trimmed_arguments]):
message = (
"Environment variable '%s' contains a blocked argument, please validate it does not contain: %s"
% (
env_var,
TF_BLOCKED_ARGUMENTS,
)
)
raise UnallowedEnvironmentVariableArgumentException(message)
4 changes: 4 additions & 0 deletions samcli/lib/hook/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ class PrepareHookException(UserException):

class TerraformCloudException(UserException):
pass


class UnallowedEnvironmentVariableArgumentException(UserException):
pass
33 changes: 26 additions & 7 deletions samcli/lib/telemetry/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class EventName(Enum):
SYNC_FLOW_END = "SyncFlowEnd"
BUILD_WORKFLOW_USED = "BuildWorkflowUsed"
CONFIG_FILE_EXTENSION = "SamConfigFileExtension"
HOOK_CONFIGURATIONS_USED = "HookConfigurationsUsed"


class UsedFeature(Enum):
Expand Down Expand Up @@ -61,6 +62,10 @@ class EventType:
]
_WORKFLOWS = [f"{config.language}-{config.dependency_manager}" for config in ALL_CONFIGS]

_HOOK_CONFIGURATIONS = [
"TerraformPlanFile",
]

_event_values = { # Contains allowable values for Events
EventName.USED_FEATURE: [event.value for event in UsedFeature],
EventName.BUILD_FUNCTION_RUNTIME: INIT_RUNTIMES,
Expand All @@ -72,6 +77,7 @@ class EventType:
EventName.SYNC_FLOW_END: _SYNC_FLOWS,
EventName.BUILD_WORKFLOW_USED: _WORKFLOWS,
EventName.CONFIG_FILE_EXTENSION: list(FILE_MANAGER_MAPPER.keys()),
EventName.HOOK_CONFIGURATIONS_USED: _HOOK_CONFIGURATIONS,
}

@staticmethod
Expand Down Expand Up @@ -148,6 +154,7 @@ class EventTracker:
_events: List[Event] = []
_event_lock = threading.Lock()
_session_id: Optional[str] = None
_command_name: Optional[str] = None

MAX_EVENTS: int = 50 # Maximum number of events to store before sending

Expand Down Expand Up @@ -205,8 +212,9 @@ def track_event(
Event(event_name, event_value, thread_id=thread_id, exception_name=exception_name)
)

# Get the session ID (needed for multithreading sending)
EventTracker._set_session_id()
# Get properties from the click context (needed for multithreading sending)
EventTracker._set_context_property("_session_id", "session_id")
EventTracker._set_context_property("_command_name", "command_path")

if len(EventTracker._events) >= EventTracker.MAX_EVENTS:
should_send = True
Expand Down Expand Up @@ -235,17 +243,27 @@ def send_events() -> threading.Thread:
return send_thread

@staticmethod
def _set_session_id() -> None:
def _set_context_property(event_prop: str, context_prop: str) -> None:
"""
Get the session ID from click and save it locally.
Set a click context property in the event so that it is emitted when the metric is sent.
This is required since the event is sent in a separate thread and no longer has access
to the click context that the command was initially called with. As a workaround, we set
the property here first so that it's available when calling the metrics endpoint.

Parameters
----------
event_prop: str
Property name to be stored in the event and consumed when emitting the metric
context_prop: str
Property name for the target property from the context object
"""
if not EventTracker._session_id:
if not getattr(EventTracker, event_prop):
try:
ctx = Context.get_current_context()
if ctx:
EventTracker._session_id = ctx.session_id
setattr(EventTracker, event_prop, getattr(ctx, context_prop))
except RuntimeError:
LOG.debug("EventTracker: Unable to obtain session ID")
LOG.debug("EventTracker: Unable to obtain %s", context_prop)

@staticmethod
def _send_events_in_thread():
Expand All @@ -264,6 +282,7 @@ def _send_events_in_thread():
telemetry = Telemetry()
metric = Metric("events")
metric.add_data("sessionId", EventTracker._session_id)
metric.add_data("commandName", EventTracker._command_name)
metric.add_data("metricSpecificAttributes", msa)
telemetry.emit(metric)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -466,3 +466,37 @@ def test_build_and_invoke_lambda_functions(self, function_identifier, expected_o
overrides=None,
expected_result={"statusCode": 200, "body": expected_output},
)


@skipIf(
(not RUN_BY_CANARY and not CI_OVERRIDE),
"Skip Terraform test cases unless running in CI",
)
class TestBuildTerraformApplicationsWithBlockedEnvironVariables(BuildTerraformApplicationIntegBase):
terraform_application = Path("terraform/simple_application")

@parameterized.expand(
[
("TF_CLI_ARGS", "-destroy"),
("TF_CLI_ARGS", "-target=some.module"),
("TF_CLI_ARGS_plan", "-destroy"),
("TF_CLI_ARGS_plan", "-target=some.module"),
("TF_CLI_ARGS_apply", "-destroy"),
("TF_CLI_ARGS_apply", "-target=some.module"),
]
)
def test_blocked_env_variables(self, env_name, env_value):
cmdlist = self.get_command_list(hook_name="terraform", beta_features=True)

env_variables = os.environ.copy()
env_variables[env_name] = env_value

_, stderr, return_code = self.run_command(cmdlist, env=env_variables)

process_stderr = stderr.strip()
self.assertRegex(
process_stderr.decode("utf-8"),
"Error: Environment variable '%s' contains a blocked argument, please validate it does not contain: ['-destroy', '-target']"
% env_name,
)
self.assertNotEqual(return_code, 0)
Loading