diff --git a/requirements/base.txt b/requirements/base.txt index 987bb98d43..e67165ad46 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -6,6 +6,7 @@ jmespath~=1.0.1 ruamel_yaml~=0.18.15 PyYAML~=6.0 cookiecutter~=2.6.0 +python-dotenv~=1.0.0 aws-sam-translator==1.101.0 #docker minor version updates can include breaking changes. Auto update micro version only. docker~=7.1.0 diff --git a/requirements/reproducible-linux.txt b/requirements/reproducible-linux.txt index 02e39508c3..97d75886bc 100644 --- a/requirements/reproducible-linux.txt +++ b/requirements/reproducible-linux.txt @@ -4,6 +4,9 @@ # # pip-compile --allow-unsafe --generate-hashes --output-file=requirements/reproducible-linux.txt # +--extra-index-url https://plugin.us-east-1.prod.workshops.aws +--trusted-host plugin.us-east-1.prod.workshops.aws + annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 @@ -661,6 +664,10 @@ python-dateutil==2.9.0.post0 \ # arrow # botocore # dateparser +python-dotenv==1.0.1 \ + --hash=sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca \ + --hash=sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a + # via aws-sam-cli (setup.py) python-slugify==8.0.4 \ --hash=sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8 \ --hash=sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856 diff --git a/requirements/reproducible-mac.txt b/requirements/reproducible-mac.txt index fc05091666..d68557fa68 100644 --- a/requirements/reproducible-mac.txt +++ b/requirements/reproducible-mac.txt @@ -4,6 +4,9 @@ # # pip-compile --allow-unsafe --generate-hashes --output-file=requirements/reproducible-mac.txt # +--extra-index-url https://plugin.us-east-1.prod.workshops.aws +--trusted-host plugin.us-east-1.prod.workshops.aws + annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 @@ -661,6 +664,10 @@ python-dateutil==2.9.0.post0 \ # arrow # botocore # dateparser +python-dotenv==1.0.1 \ + --hash=sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca \ + --hash=sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a + # via aws-sam-cli (setup.py) python-slugify==8.0.4 \ --hash=sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8 \ --hash=sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856 diff --git a/samcli/commands/local/cli_common/invoke_context.py b/samcli/commands/local/cli_common/invoke_context.py index 13677ecaa5..0cadbd07b7 100644 --- a/samcli/commands/local/cli_common/invoke_context.py +++ b/samcli/commands/local/cli_common/invoke_context.py @@ -10,6 +10,8 @@ from pathlib import Path from typing import Any, Dict, List, Optional, TextIO, Tuple, Type, cast +from dotenv import dotenv_values + from samcli.commands._utils.template import TemplateFailedParsingException, TemplateNotFoundException from samcli.commands.exceptions import ContainersInitializationException from samcli.commands.local.cli_common.user_exceptions import DebugContextException, InvokeContextException @@ -76,6 +78,7 @@ def __init__( template_file: str, function_identifier: Optional[str] = None, env_vars_file: Optional[str] = None, + dotenv_file: Optional[str] = None, docker_volume_basedir: Optional[str] = None, docker_network: Optional[str] = None, log_file: Optional[str] = None, @@ -84,6 +87,7 @@ def __init__( debug_args: Optional[str] = None, debugger_path: Optional[str] = None, container_env_vars_file: Optional[str] = None, + container_dotenv_file: Optional[str] = None, parameter_overrides: Optional[Dict] = None, layer_cache_basedir: Optional[str] = None, force_image_build: Optional[bool] = None, @@ -110,6 +114,8 @@ def __init__( Identifier of the function to invoke env_vars_file str Path to a file containing values for environment variables + dotenv_file str + Path to .env file containing environment variables docker_volume_basedir str Directory for the Docker volume docker_network str @@ -126,6 +132,10 @@ def __init__( Additional arguments passed to the debugger debugger_path str Path to the directory of the debugger to mount on Docker + container_env_vars_file str + Path to a file containing values for container environment variables + container_dotenv_file str + Path to .env file containing container environment variables parameter_overrides dict Values for the template parameters layer_cache_basedir str @@ -159,6 +169,7 @@ def __init__( self._template_file = template_file self._function_identifier = function_identifier self._env_vars_file = env_vars_file + self._dotenv_file = dotenv_file self._docker_volume_basedir = docker_volume_basedir self._docker_network = docker_network self._log_file = log_file @@ -167,6 +178,7 @@ def __init__( self._debug_args = debug_args self._debugger_path = debugger_path self._container_env_vars_file = container_env_vars_file + self._container_dotenv_file = container_dotenv_file self._parameter_overrides = parameter_overrides # Override certain CloudFormation pseudo-parameters based on values provided by customer @@ -245,8 +257,25 @@ def __enter__(self) -> "InvokeContext": *_function_providers_args[self._containers_mode] ) - self._env_vars_value = self._get_env_vars_value(self._env_vars_file) - self._container_env_vars_value = self._get_env_vars_value(self._container_env_vars_file) + # Load and merge Lambda runtime environment variables + # Dotenv values are loaded first with function-specific parsing enabled + # Then JSON env_vars can override them + # Lambda env vars support hierarchical structure with Parameters and function-specific sections + dotenv_vars = self._get_dotenv_values(self._dotenv_file, parse_function_specific=True) + env_vars = self._get_env_vars_value(self._env_vars_file) + self._env_vars_value = self._merge_env_vars(dotenv_vars, env_vars, wrap_in_parameters=True) + + # Load and merge container environment variables (used for debugging) + # Container env vars remain flat (not wrapped in Parameters, no function-specific parsing) + container_dotenv_vars = self._get_dotenv_values(self._container_dotenv_file, parse_function_specific=False) + container_env_vars = self._get_env_vars_value(self._container_env_vars_file) + self._container_env_vars_value = self._merge_env_vars( + container_dotenv_vars, container_env_vars, wrap_in_parameters=False + ) + + LOG.debug("Final env vars value: %s", self._env_vars_value) + LOG.debug("Final container env vars value: %s", self._container_env_vars_value) + self._log_file_handle = self._setup_log_file(self._log_file) # in case of warm containers && debugging is enabled && if debug-function property is not provided, so @@ -623,6 +652,174 @@ def _get_stacks(self) -> List[Stack]: LOG.debug("Can't read stacks information, either template is not found or it is invalid", exc_info=ex) raise ex + @staticmethod + def _merge_env_vars( + dotenv_vars: Optional[Dict], json_env_vars: Optional[Dict], wrap_in_parameters: bool + ) -> Optional[Dict]: + """ + Merge environment variables from .env file and JSON file, with JSON taking precedence. + + When wrap_in_parameters=True (Lambda env vars): + - dotenv_vars may already have hierarchical structure: {"Parameters": {...}, "FunctionName": {...}} + - If dotenv_vars is flat, wrap it in Parameters + - json_env_vars merges with Parameters section and preserves function-specific sections + + When wrap_in_parameters=False (container env vars): + - Both dotenv_vars and json_env_vars should be flat + - Simple key-value merge with JSON taking precedence + + :param dict dotenv_vars: Variables loaded from .env file (may be hierarchical or flat) + :param dict json_env_vars: Variables loaded from JSON file + :param bool wrap_in_parameters: If True, ensure hierarchical structure for Lambda env vars + :return dict: Merged environment variables, or None if both inputs are None + """ + # Handle mocked test scenarios where json_env_vars might not be a dict + # This check must come before other logic to handle Mock objects properly + if json_env_vars is not None and not isinstance(json_env_vars, dict): + return json_env_vars # type: ignore[return-value, unreachable] + + # If both inputs are empty, return None early + if not dotenv_vars and not json_env_vars: + return None + + # Initialize result based on dotenv_vars structure + if not dotenv_vars: + result = {} + elif wrap_in_parameters: + # Check if dotenv_vars is already hierarchical (has Parameters key) + if "Parameters" in dotenv_vars: + # Already hierarchical from parse_function_specific=True + result = {k: v.copy() if isinstance(v, dict) else v for k, v in dotenv_vars.items()} + else: + # Flat structure, wrap in Parameters + result = {"Parameters": dotenv_vars.copy()} + else: + # Container mode: keep flat + result = dotenv_vars.copy() + + # Merge JSON env vars with precedence + if json_env_vars: + if wrap_in_parameters: + # Lambda env vars mode: handle hierarchical structure + if "Parameters" in json_env_vars: + # Merge Parameters sections, with json_env_vars taking precedence + if "Parameters" not in result: + result["Parameters"] = {} + result["Parameters"] = {**result.get("Parameters", {}), **json_env_vars["Parameters"]} + + # Merge function-specific sections and other keys + for key, value in json_env_vars.items(): + if key != "Parameters": + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + # Merge function-specific dicts, JSON takes precedence + result[key] = {**result[key], **value} + else: + # Simple override + result[key] = value + else: + # Container env vars mode: simple flat merge + result.update(json_env_vars) + + return result if result else None + + @staticmethod + def _get_dotenv_values(filename: Optional[str], parse_function_specific: bool = False) -> Optional[Dict]: + """ + If the user provided a .env file, this method will read the file and return its values as a dictionary. + Optionally parses function-specific environment variables using FunctionName_VAR pattern. + + :param string filename: Path to .env file containing environment variable values + :param bool parse_function_specific: If True, parse variables with FunctionName_VAR pattern into + function-specific sections. If False, returns flat dictionary. + :return dict: Value of environment variables from .env file, if provided. None otherwise + When parse_function_specific=True, returns hierarchical structure: + {"Parameters": {...global vars...}, "FunctionName": {...function vars...}} + :raises InvokeContextException: If the file was not found or could not be parsed + """ + if not filename: + return None + + # Check if file exists before attempting to read + if not os.path.exists(filename): + raise InvalidEnvironmentVariablesFileException("Environment variables file not found: {}".format(filename)) + + try: + # dotenv_values returns a dictionary with all variables from the .env file + # It handles comments, quotes, multiline values, etc. + env_dict = dotenv_values(filename) + + # Log warning if file is empty or couldn't be parsed + if not env_dict: + LOG.warning("The .env file '%s' is empty or contains no valid environment variables", filename) + + # Filter out None values and convert to strings + clean_dict = {k: str(v) if v is not None else "" for k, v in env_dict.items()} + + # If not parsing function-specific vars, return flat structure + if not parse_function_specific: + return clean_dict + + # Parse function-specific variables + return InvokeContext._parse_function_specific_env_vars(clean_dict) + + except Exception as ex: + raise InvalidEnvironmentVariablesFileException( + "Could not read environment variables from .env file {}: {}".format(filename, str(ex)) + ) from ex + + @staticmethod + def _parse_function_specific_env_vars(env_dict: Dict[str, str]) -> Dict: + """ + Parse environment variables to separate global from function-specific variables. + + Variables are classified as function-specific if they match the pattern: FunctionName*VAR + (note the asterisk separator). This unambiguous separator works with any function + naming convention (PascalCase, camelCase, snake_case, etc.) and cannot be used in + CloudFormation logical IDs. + + Examples: + MyFunction*API_KEY -> Function-specific for MyFunction + HelloWorld*TIMEOUT -> Function-specific for HelloWorld + myFunction*API_KEY -> Function-specific for myFunction + my_function*TIMEOUT -> Function-specific for my_function + LAMBDA_VAR -> Global (no asterisk) + API_KEY -> Global (no asterisk) + MY_FUNCTION_VAR -> Global (underscore, not asterisk) + + :param dict env_dict: Flat dictionary of environment variables + :return dict: Hierarchical structure with Parameters and function-specific sections + """ + result: Dict[str, Dict[str, str]] = {"Parameters": {}} + + for key, value in env_dict.items(): + # Check if variable contains asterisk separator + if "*" not in key: + # No asterisk -> global variable + result["Parameters"][key] = value + continue + + # Split by first occurrence of asterisk to get function name and variable name + parts = key.split("*", 1) + if len(parts) < 2: # noqa: PLR2004 + # Edge case: variable has asterisk but split failed somehow + result["Parameters"][key] = value + continue + + function_name, var_name = parts + + # Validate that both parts are non-empty + if not function_name or not var_name: + # Treat as global if either part is empty (e.g., "*VAR" or "FUNCTION*") + result["Parameters"][key] = value + continue + + # Function-specific variable: FunctionName*VAR + if function_name not in result: + result[function_name] = {} + result[function_name][var_name] = value + + return result + @staticmethod def _get_env_vars_value(filename: Optional[str]) -> Optional[Dict]: """ diff --git a/samcli/commands/local/cli_common/options.py b/samcli/commands/local/cli_common/options.py index a154481421..876fb1ee0c 100644 --- a/samcli/commands/local/cli_common/options.py +++ b/samcli/commands/local/cli_common/options.py @@ -174,6 +174,14 @@ def invoke_common_options(f): type=click.Path(exists=True), help="JSON file containing values for Lambda function's environment variables.", ), + click.option( + "--dotenv", + type=click.Path(exists=True), + help="Path to a .env file containing environment variables for Lambda functions. " + "Variables defined here will apply to all functions. " + "If both --env-vars and --dotenv are provided, variables from both will be merged " + "with --env-vars taking precedence.", + ), parameter_override_click_option(), click.option( "--debug-port", @@ -196,6 +204,14 @@ def invoke_common_options(f): help="JSON file containing additional environment variables to be set within the container when " "used in a debugging session locally.", ), + click.option( + "--container-dotenv", + type=click.Path(exists=True), + help="Path to a .env file containing additional environment variables to be set within the container " + "when used in a debugging session locally. " + "If both --container-env-vars and --container-dotenv are provided, variables from both will be merged " + "with --container-env-vars taking precedence.", + ), click.option( "--docker-volume-basedir", "-v", diff --git a/samcli/commands/local/invoke/cli.py b/samcli/commands/local/invoke/cli.py index 73f79fa971..3bfe57a92b 100644 --- a/samcli/commands/local/invoke/cli.py +++ b/samcli/commands/local/invoke/cli.py @@ -92,10 +92,12 @@ def cli( event, no_event, env_vars, + dotenv, debug_port, debug_args, debugger_path, container_env_vars, + container_dotenv, docker_volume_basedir, docker_network, log_file, @@ -130,10 +132,12 @@ def cli( event, no_event, env_vars, + dotenv, debug_port, debug_args, debugger_path, container_env_vars, + container_dotenv, docker_volume_basedir, docker_network, log_file, @@ -160,10 +164,12 @@ def do_cli( # pylint: disable=R0914 event, no_event, env_vars, + dotenv, debug_port, debug_args, debugger_path, container_env_vars, + container_dotenv, docker_volume_basedir, docker_network, log_file, @@ -211,6 +217,7 @@ def do_cli( # pylint: disable=R0914 template_file=template, function_identifier=function_identifier, env_vars_file=env_vars, + dotenv_file=dotenv, docker_volume_basedir=docker_volume_basedir, docker_network=docker_network, log_file=log_file, @@ -219,6 +226,7 @@ def do_cli( # pylint: disable=R0914 debug_args=debug_args, debugger_path=debugger_path, container_env_vars_file=container_env_vars, + container_dotenv_file=container_dotenv, parameter_overrides=parameter_overrides, layer_cache_basedir=layer_cache_basedir, force_image_build=force_image_build, diff --git a/samcli/commands/local/invoke/core/options.py b/samcli/commands/local/invoke/core/options.py index a0b4b4f6cd..9c2ebee20c 100644 --- a/samcli/commands/local/invoke/core/options.py +++ b/samcli/commands/local/invoke/core/options.py @@ -22,7 +22,9 @@ "event", "no_event", "env_vars", + "dotenv", "container_env_vars", + "container_dotenv", "debug_port", "debugger_path", "debug_args", diff --git a/samcli/commands/local/lib/debug_context.py b/samcli/commands/local/lib/debug_context.py index d73b735d06..0e645b6269 100644 --- a/samcli/commands/local/lib/debug_context.py +++ b/samcli/commands/local/lib/debug_context.py @@ -25,7 +25,13 @@ def __init__( self.container_env_vars = container_env_vars def __bool__(self): - return bool(self.debug_ports) + # DebugContext is "truthy" if we have either debug ports OR container env vars. + # Previously, this only checked debug_ports. However, with the addition of --container-dotenv + # support, container_env_vars can exist independently (for passing environment variables to + # the container for debugging purposes, even without debugger ports). Therefore, we consider + # the DebugContext active if either debugging ports are specified OR container environment + # variables are provided. + return bool(self.debug_ports or self.container_env_vars) def __nonzero__(self): return self.__bool__() diff --git a/samcli/commands/local/start_api/cli.py b/samcli/commands/local/start_api/cli.py index bd9d36ead5..ab32d6dc59 100644 --- a/samcli/commands/local/start_api/cli.py +++ b/samcli/commands/local/start_api/cli.py @@ -115,10 +115,12 @@ def cli( # Common Options for Lambda Invoke template_file, env_vars, + dotenv, debug_port, debug_args, debugger_path, container_env_vars, + container_dotenv, docker_volume_basedir, docker_network, log_file, @@ -156,10 +158,12 @@ def cli( static_dir, template_file, env_vars, + dotenv, debug_port, debug_args, debugger_path, container_env_vars, + container_dotenv, docker_volume_basedir, docker_network, log_file, @@ -189,10 +193,12 @@ def do_cli( # pylint: disable=R0914 static_dir, template, env_vars, + dotenv, debug_port, debug_args, debugger_path, container_env_vars, + container_dotenv, docker_volume_basedir, docker_network, log_file, @@ -236,6 +242,7 @@ def do_cli( # pylint: disable=R0914 template_file=template, function_identifier=None, # Don't scope to one particular function env_vars_file=env_vars, + dotenv_file=dotenv, docker_volume_basedir=docker_volume_basedir, docker_network=docker_network, log_file=log_file, @@ -244,6 +251,7 @@ def do_cli( # pylint: disable=R0914 debug_args=debug_args, debugger_path=debugger_path, container_env_vars_file=container_env_vars, + container_dotenv_file=container_dotenv, parameter_overrides=parameter_overrides, layer_cache_basedir=layer_cache_basedir, force_image_build=force_image_build, diff --git a/samcli/commands/local/start_api/core/options.py b/samcli/commands/local/start_api/core/options.py index 750b8c6d15..f2cc17b512 100644 --- a/samcli/commands/local/start_api/core/options.py +++ b/samcli/commands/local/start_api/core/options.py @@ -26,7 +26,9 @@ "ssl_cert_file", "ssl_key_file", "env_vars", + "dotenv", "container_env_vars", + "container_dotenv", "debug_port", "debugger_path", "debug_args", diff --git a/samcli/commands/local/start_lambda/cli.py b/samcli/commands/local/start_lambda/cli.py index f4df9156a0..60c89e8045 100644 --- a/samcli/commands/local/start_lambda/cli.py +++ b/samcli/commands/local/start_lambda/cli.py @@ -77,10 +77,12 @@ def cli( # Common Options for Lambda Invoke template_file, env_vars, + dotenv, debug_port, debug_args, debugger_path, container_env_vars, + container_dotenv, docker_volume_basedir, docker_network, log_file, @@ -114,10 +116,12 @@ def cli( port, template_file, env_vars, + dotenv, debug_port, debug_args, debugger_path, container_env_vars, + container_dotenv, docker_volume_basedir, docker_network, log_file, @@ -143,10 +147,12 @@ def do_cli( # pylint: disable=R0914 port, template, env_vars, + dotenv, debug_port, debug_args, debugger_path, container_env_vars, + container_dotenv, docker_volume_basedir, docker_network, log_file, @@ -188,6 +194,7 @@ def do_cli( # pylint: disable=R0914 template_file=template, function_identifier=None, # Don't scope to one particular function env_vars_file=env_vars, + dotenv_file=dotenv, docker_volume_basedir=docker_volume_basedir, docker_network=docker_network, log_file=log_file, @@ -196,6 +203,7 @@ def do_cli( # pylint: disable=R0914 debug_args=debug_args, debugger_path=debugger_path, container_env_vars_file=container_env_vars, + container_dotenv_file=container_dotenv, parameter_overrides=parameter_overrides, layer_cache_basedir=layer_cache_basedir, force_image_build=force_image_build, diff --git a/samcli/commands/local/start_lambda/core/options.py b/samcli/commands/local/start_lambda/core/options.py index 4eecf90483..d6df13177f 100644 --- a/samcli/commands/local/start_lambda/core/options.py +++ b/samcli/commands/local/start_lambda/core/options.py @@ -22,8 +22,10 @@ "host", "port", "env_vars", + "dotenv", "warm_containers", "container_env_vars", + "container_dotenv", "debug_function", "debug_port", "debugger_path", diff --git a/samcli/local/docker/lambda_container.py b/samcli/local/docker/lambda_container.py index 761cca7f1d..58d0250932 100644 --- a/samcli/local/docker/lambda_container.py +++ b/samcli/local/docker/lambda_container.py @@ -290,12 +290,18 @@ def _get_debug_settings(runtime, debug_options=None): # pylint: disable=too-man entry = LambdaContainer._get_default_entry_point() if not debug_options: + LOG.debug("No debug_options provided, returning empty container env vars") return entry, {} debug_ports = debug_options.debug_ports - container_env_vars = debug_options.container_env_vars + container_env_vars = debug_options.container_env_vars if debug_options.container_env_vars else {} + + LOG.debug("Debug settings - debug_ports: %s, container_env_vars: %s", debug_ports, container_env_vars) + if not debug_ports: - return entry, {} + # Even without debug ports, we should still return container env vars if they exist + LOG.debug("No debug ports, but returning container_env_vars: %s", container_env_vars) + return entry, container_env_vars debug_port = debug_ports[0] debug_args_list = [] diff --git a/samcli/local/lambdafn/env_vars.py b/samcli/local/lambdafn/env_vars.py index 87d0ae280b..5f51a1d76d 100644 --- a/samcli/local/lambdafn/env_vars.py +++ b/samcli/local/lambdafn/env_vars.py @@ -95,15 +95,25 @@ def resolve(self): # AWS_* variables must always be passed to the function, but user has the choice to override them result = self._get_aws_variables() - # Default value for the variable gets lowest priority - for name, value in self.variables.items(): - override_value = value + # Collect all variable names from all sources + # Previously, only variables defined in the template (self.variables) were processed, + # which meant shell_env and override values for undeclared variables were ignored. + # Now we collect ALL variable names from all three sources to ensure variables from + # shell environment and override files (like .env) are included even if not in template. + all_var_names = ( + set(self.variables.keys()) | set(self.shell_env_values.keys()) | set(self.override_values.keys()) + ) + + # Resolve each variable with proper precedence + for name in all_var_names: + # Default value from template gets lowest priority + override_value = self.variables.get(name, self._BLANK_VALUE) # Shell environment values, second priority if name in self.shell_env_values: override_value = self.shell_env_values[name] - # Overridden values, highest priority + # Overridden values (from --env-vars or --dotenv), highest priority if name in self.override_values: override_value = self.override_values[name] diff --git a/schema/samcli.json b/schema/samcli.json index 5203d60bc7..5e679efa32 100644 --- a/schema/samcli.json +++ b/schema/samcli.json @@ -408,7 +408,7 @@ "properties": { "parameters": { "title": "Parameters for the local invoke command", - "description": "Available parameters for the local invoke command:\n* terraform_plan_file:\nUsed for passing a custom plan file when executing the Terraform hook.\n* hook_name:\nHook package id to extend AWS SAM CLI commands functionality. \n\nExample: `terraform` to extend AWS SAM CLI commands functionality to support terraform applications. \n\nAvailable Hook Names: ['terraform']\n* skip_prepare_infra:\nSkip preparation stage when there are no infrastructure changes. Only used in conjunction with --hook-name.\n* event:\nJSON file containing event data passed to the Lambda function during invoke. If this option is not specified, no event is assumed. Pass in the value '-' to input JSON via stdin\n* no_event:\nDEPRECATED: By default no event is assumed.\n* runtime:\nLambda runtime used to invoke the function.\n\nRuntimes: dotnet8, dotnet6, go1.x, java21, java17, java11, java8.al2, nodejs22.x, nodejs20.x, nodejs18.x, nodejs16.x, provided, provided.al2, provided.al2023, python3.9, python3.8, python3.13, python3.12, python3.11, python3.10, ruby3.4, ruby3.3, ruby3.2\n* mount_symlinks:\nSpecify if symlinks at the top level of the code should be mounted inside the container. Activating this flag could allow access to locations outside of your workspace by using a symbolic link. By default symlinks are not mounted.\n* template_file:\nAWS SAM template which references built artifacts for resources in the template. (if applicable)\n* env_vars:\nJSON file containing values for Lambda function's environment variables.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* debug_port:\nWhen specified, Lambda function container will start in debug mode and will expose this port on localhost.\n* debugger_path:\nHost path to a debugger that will be mounted into the Lambda container.\n* debug_args:\nAdditional arguments to be passed to the debugger.\n* container_env_vars:\nJSON file containing additional environment variables to be set within the container when used in a debugging session locally.\n* docker_volume_basedir:\nSpecify the location basedir where the SAM template exists. If Docker is running on a remote machine, Path of the SAM template must be mounted on the Docker machine and modified to match the remote machine.\n* log_file:\nFile to capture output logs.\n* layer_cache_basedir:\nSpecify the location basedir where the lambda layers used by the template will be downloaded to.\n* skip_pull_image:\nSkip pulling down the latest Docker image for Lambda runtime.\n* docker_network:\nName or ID of an existing docker network for AWS Lambda docker containers to connect to, along with the default bridge network. If not specified, the Lambda containers will only connect to the default bridge docker network.\n* force_image_build:\nForce rebuilding the image used for invoking functions with layers.\n* shutdown:\nEmulate a shutdown event after invoke completes, to test extension handling of shutdown behavior.\n* container_host:\nHost of locally emulated Lambda container. This option is useful when the container runs on a different host than AWS SAM CLI. For example, if one wants to run AWS SAM CLI in a Docker container on macOS, this option could specify `host.docker.internal`\n* container_host_interface:\nIP address of the host network interface that container ports should bind to. Use 0.0.0.0 to bind to all interfaces.\n* add_host:\nPasses a hostname to IP address mapping to the Docker container's host file. This parameter can be passed multiple times.Example:--add-host example.com:127.0.0.1\n* invoke_image:\nContainer image URIs for invoking functions or starting api and function. One can specify the image URI used for the local function invocation (--invoke-image public.ecr.aws/sam/build-nodejs20.x:latest). One can also specify for each individual function with (--invoke-image Function1=public.ecr.aws/sam/build-nodejs20.x:latest). If a function does not have invoke image specified, the default AWS SAM CLI emulation image will be used.\n* no_memory_limit:\nRemoves the Memory limit during emulation. With this parameter, the underlying container will run without a --memory parameter\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.", + "description": "Available parameters for the local invoke command:\n* terraform_plan_file:\nUsed for passing a custom plan file when executing the Terraform hook.\n* hook_name:\nHook package id to extend AWS SAM CLI commands functionality. \n\nExample: `terraform` to extend AWS SAM CLI commands functionality to support terraform applications. \n\nAvailable Hook Names: ['terraform']\n* skip_prepare_infra:\nSkip preparation stage when there are no infrastructure changes. Only used in conjunction with --hook-name.\n* event:\nJSON file containing event data passed to the Lambda function during invoke. If this option is not specified, no event is assumed. Pass in the value '-' to input JSON via stdin\n* no_event:\nDEPRECATED: By default no event is assumed.\n* runtime:\nLambda runtime used to invoke the function.\n\nRuntimes: dotnet8, dotnet6, go1.x, java21, java17, java11, java8.al2, nodejs22.x, nodejs20.x, nodejs18.x, nodejs16.x, provided, provided.al2, provided.al2023, python3.9, python3.8, python3.13, python3.12, python3.11, python3.10, ruby3.4, ruby3.3, ruby3.2\n* mount_symlinks:\nSpecify if symlinks at the top level of the code should be mounted inside the container. Activating this flag could allow access to locations outside of your workspace by using a symbolic link. By default symlinks are not mounted.\n* template_file:\nAWS SAM template which references built artifacts for resources in the template. (if applicable)\n* env_vars:\nJSON file containing values for Lambda function's environment variables.\n* dotenv:\nPath to a .env file containing environment variables for Lambda functions. Variables defined here will apply to all functions. If both --env-vars and --dotenv are provided, variables from both will be merged with --env-vars taking precedence.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* debug_port:\nWhen specified, Lambda function container will start in debug mode and will expose this port on localhost.\n* debugger_path:\nHost path to a debugger that will be mounted into the Lambda container.\n* debug_args:\nAdditional arguments to be passed to the debugger.\n* container_env_vars:\nJSON file containing additional environment variables to be set within the container when used in a debugging session locally.\n* container_dotenv:\nPath to a .env file containing additional environment variables to be set within the container when used in a debugging session locally. If both --container-env-vars and --container-dotenv are provided, variables from both will be merged with --container-env-vars taking precedence.\n* docker_volume_basedir:\nSpecify the location basedir where the SAM template exists. If Docker is running on a remote machine, Path of the SAM template must be mounted on the Docker machine and modified to match the remote machine.\n* log_file:\nFile to capture output logs.\n* layer_cache_basedir:\nSpecify the location basedir where the lambda layers used by the template will be downloaded to.\n* skip_pull_image:\nSkip pulling down the latest Docker image for Lambda runtime.\n* docker_network:\nName or ID of an existing docker network for AWS Lambda docker containers to connect to, along with the default bridge network. If not specified, the Lambda containers will only connect to the default bridge docker network.\n* force_image_build:\nForce rebuilding the image used for invoking functions with layers.\n* shutdown:\nEmulate a shutdown event after invoke completes, to test extension handling of shutdown behavior.\n* container_host:\nHost of locally emulated Lambda container. This option is useful when the container runs on a different host than AWS SAM CLI. For example, if one wants to run AWS SAM CLI in a Docker container on macOS, this option could specify `host.docker.internal`\n* container_host_interface:\nIP address of the host network interface that container ports should bind to. Use 0.0.0.0 to bind to all interfaces.\n* add_host:\nPasses a hostname to IP address mapping to the Docker container's host file. This parameter can be passed multiple times.Example:--add-host example.com:127.0.0.1\n* invoke_image:\nContainer image URIs for invoking functions or starting api and function. One can specify the image URI used for the local function invocation (--invoke-image public.ecr.aws/sam/build-nodejs20.x:latest). One can also specify for each individual function with (--invoke-image Function1=public.ecr.aws/sam/build-nodejs20.x:latest). If a function does not have invoke image specified, the default AWS SAM CLI emulation image will be used.\n* no_memory_limit:\nRemoves the Memory limit during emulation. With this parameter, the underlying container will run without a --memory parameter\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.", "type": "object", "properties": { "terraform_plan_file": { @@ -483,6 +483,11 @@ "type": "string", "description": "JSON file containing values for Lambda function's environment variables." }, + "dotenv": { + "title": "dotenv", + "type": "string", + "description": "Path to a .env file containing environment variables for Lambda functions. Variables defined here will apply to all functions. If both --env-vars and --dotenv are provided, variables from both will be merged with --env-vars taking precedence." + }, "parameter_overrides": { "title": "parameter_overrides", "type": [ @@ -514,6 +519,11 @@ "type": "string", "description": "JSON file containing additional environment variables to be set within the container when used in a debugging session locally." }, + "container_dotenv": { + "title": "container_dotenv", + "type": "string", + "description": "Path to a .env file containing additional environment variables to be set within the container when used in a debugging session locally. If both --container-env-vars and --container-dotenv are provided, variables from both will be merged with --container-env-vars taking precedence." + }, "docker_volume_basedir": { "title": "docker_volume_basedir", "type": "string", @@ -617,7 +627,7 @@ "properties": { "parameters": { "title": "Parameters for the local start api command", - "description": "Available parameters for the local start api command:\n* terraform_plan_file:\nUsed for passing a custom plan file when executing the Terraform hook.\n* hook_name:\nHook package id to extend AWS SAM CLI commands functionality. \n\nExample: `terraform` to extend AWS SAM CLI commands functionality to support terraform applications. \n\nAvailable Hook Names: ['terraform']\n* skip_prepare_infra:\nSkip preparation stage when there are no infrastructure changes. Only used in conjunction with --hook-name.\n* host:\nLocal hostname or IP address to bind to (default: '127.0.0.1')\n* port:\nLocal port number to listen on (default: '3000')\n* static_dir:\nAny static assets (e.g. CSS/Javascript/HTML) files located in this directory will be presented at /\n* disable_authorizer:\nDisable custom Lambda Authorizers from being parsed and invoked.\n* ssl_cert_file:\nPath to SSL certificate file (default: None)\n* ssl_key_file:\nPath to SSL key file (default: None)\n* template_file:\nAWS SAM template which references built artifacts for resources in the template. (if applicable)\n* env_vars:\nJSON file containing values for Lambda function's environment variables.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* debug_port:\nWhen specified, Lambda function container will start in debug mode and will expose this port on localhost.\n* debugger_path:\nHost path to a debugger that will be mounted into the Lambda container.\n* debug_args:\nAdditional arguments to be passed to the debugger.\n* container_env_vars:\nJSON file containing additional environment variables to be set within the container when used in a debugging session locally.\n* docker_volume_basedir:\nSpecify the location basedir where the SAM template exists. If Docker is running on a remote machine, Path of the SAM template must be mounted on the Docker machine and modified to match the remote machine.\n* log_file:\nFile to capture output logs.\n* layer_cache_basedir:\nSpecify the location basedir where the lambda layers used by the template will be downloaded to.\n* skip_pull_image:\nSkip pulling down the latest Docker image for Lambda runtime.\n* docker_network:\nName or ID of an existing docker network for AWS Lambda docker containers to connect to, along with the default bridge network. If not specified, the Lambda containers will only connect to the default bridge docker network.\n* force_image_build:\nForce rebuilding the image used for invoking functions with layers.\n* warm_containers:\nOptional. Specifies how AWS SAM CLI manages \ncontainers for each function.\nTwo modes are available:\nEAGER: Containers for all functions are \nloaded at startup and persist between \ninvocations.\nLAZY: Containers are only loaded when each \nfunction is first invoked. Those containers \npersist for additional invocations.\n* debug_function:\nOptional. Specifies the Lambda Function logicalId to apply debug options to when --warm-containers is specified. This parameter applies to --debug-port, --debugger-path, and --debug-args.\n* shutdown:\nEmulate a shutdown event after invoke completes, to test extension handling of shutdown behavior.\n* container_host:\nHost of locally emulated Lambda container. This option is useful when the container runs on a different host than AWS SAM CLI. For example, if one wants to run AWS SAM CLI in a Docker container on macOS, this option could specify `host.docker.internal`\n* container_host_interface:\nIP address of the host network interface that container ports should bind to. Use 0.0.0.0 to bind to all interfaces.\n* add_host:\nPasses a hostname to IP address mapping to the Docker container's host file. This parameter can be passed multiple times.Example:--add-host example.com:127.0.0.1\n* invoke_image:\nContainer image URIs for invoking functions or starting api and function. One can specify the image URI used for the local function invocation (--invoke-image public.ecr.aws/sam/build-nodejs20.x:latest). One can also specify for each individual function with (--invoke-image Function1=public.ecr.aws/sam/build-nodejs20.x:latest). If a function does not have invoke image specified, the default AWS SAM CLI emulation image will be used.\n* no_memory_limit:\nRemoves the Memory limit during emulation. With this parameter, the underlying container will run without a --memory parameter\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.", + "description": "Available parameters for the local start api command:\n* terraform_plan_file:\nUsed for passing a custom plan file when executing the Terraform hook.\n* hook_name:\nHook package id to extend AWS SAM CLI commands functionality. \n\nExample: `terraform` to extend AWS SAM CLI commands functionality to support terraform applications. \n\nAvailable Hook Names: ['terraform']\n* skip_prepare_infra:\nSkip preparation stage when there are no infrastructure changes. Only used in conjunction with --hook-name.\n* host:\nLocal hostname or IP address to bind to (default: '127.0.0.1')\n* port:\nLocal port number to listen on (default: '3000')\n* static_dir:\nAny static assets (e.g. CSS/Javascript/HTML) files located in this directory will be presented at /\n* disable_authorizer:\nDisable custom Lambda Authorizers from being parsed and invoked.\n* ssl_cert_file:\nPath to SSL certificate file (default: None)\n* ssl_key_file:\nPath to SSL key file (default: None)\n* template_file:\nAWS SAM template which references built artifacts for resources in the template. (if applicable)\n* env_vars:\nJSON file containing values for Lambda function's environment variables.\n* dotenv:\nPath to a .env file containing environment variables for Lambda functions. Variables defined here will apply to all functions. If both --env-vars and --dotenv are provided, variables from both will be merged with --env-vars taking precedence.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* debug_port:\nWhen specified, Lambda function container will start in debug mode and will expose this port on localhost.\n* debugger_path:\nHost path to a debugger that will be mounted into the Lambda container.\n* debug_args:\nAdditional arguments to be passed to the debugger.\n* container_env_vars:\nJSON file containing additional environment variables to be set within the container when used in a debugging session locally.\n* container_dotenv:\nPath to a .env file containing additional environment variables to be set within the container when used in a debugging session locally. If both --container-env-vars and --container-dotenv are provided, variables from both will be merged with --container-env-vars taking precedence.\n* docker_volume_basedir:\nSpecify the location basedir where the SAM template exists. If Docker is running on a remote machine, Path of the SAM template must be mounted on the Docker machine and modified to match the remote machine.\n* log_file:\nFile to capture output logs.\n* layer_cache_basedir:\nSpecify the location basedir where the lambda layers used by the template will be downloaded to.\n* skip_pull_image:\nSkip pulling down the latest Docker image for Lambda runtime.\n* docker_network:\nName or ID of an existing docker network for AWS Lambda docker containers to connect to, along with the default bridge network. If not specified, the Lambda containers will only connect to the default bridge docker network.\n* force_image_build:\nForce rebuilding the image used for invoking functions with layers.\n* warm_containers:\nOptional. Specifies how AWS SAM CLI manages \ncontainers for each function.\nTwo modes are available:\nEAGER: Containers for all functions are \nloaded at startup and persist between \ninvocations.\nLAZY: Containers are only loaded when each \nfunction is first invoked. Those containers \npersist for additional invocations.\n* debug_function:\nOptional. Specifies the Lambda Function logicalId to apply debug options to when --warm-containers is specified. This parameter applies to --debug-port, --debugger-path, and --debug-args.\n* shutdown:\nEmulate a shutdown event after invoke completes, to test extension handling of shutdown behavior.\n* container_host:\nHost of locally emulated Lambda container. This option is useful when the container runs on a different host than AWS SAM CLI. For example, if one wants to run AWS SAM CLI in a Docker container on macOS, this option could specify `host.docker.internal`\n* container_host_interface:\nIP address of the host network interface that container ports should bind to. Use 0.0.0.0 to bind to all interfaces.\n* add_host:\nPasses a hostname to IP address mapping to the Docker container's host file. This parameter can be passed multiple times.Example:--add-host example.com:127.0.0.1\n* invoke_image:\nContainer image URIs for invoking functions or starting api and function. One can specify the image URI used for the local function invocation (--invoke-image public.ecr.aws/sam/build-nodejs20.x:latest). One can also specify for each individual function with (--invoke-image Function1=public.ecr.aws/sam/build-nodejs20.x:latest). If a function does not have invoke image specified, the default AWS SAM CLI emulation image will be used.\n* no_memory_limit:\nRemoves the Memory limit during emulation. With this parameter, the underlying container will run without a --memory parameter\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.", "type": "object", "properties": { "terraform_plan_file": { @@ -679,6 +689,11 @@ "type": "string", "description": "JSON file containing values for Lambda function's environment variables." }, + "dotenv": { + "title": "dotenv", + "type": "string", + "description": "Path to a .env file containing environment variables for Lambda functions. Variables defined here will apply to all functions. If both --env-vars and --dotenv are provided, variables from both will be merged with --env-vars taking precedence." + }, "parameter_overrides": { "title": "parameter_overrides", "type": [ @@ -710,6 +725,11 @@ "type": "string", "description": "JSON file containing additional environment variables to be set within the container when used in a debugging session locally." }, + "container_dotenv": { + "title": "container_dotenv", + "type": "string", + "description": "Path to a .env file containing additional environment variables to be set within the container when used in a debugging session locally. If both --container-env-vars and --container-dotenv are provided, variables from both will be merged with --container-env-vars taking precedence." + }, "docker_volume_basedir": { "title": "docker_volume_basedir", "type": "string", @@ -842,7 +862,7 @@ "properties": { "parameters": { "title": "Parameters for the local start lambda command", - "description": "Available parameters for the local start lambda command:\n* terraform_plan_file:\nUsed for passing a custom plan file when executing the Terraform hook.\n* hook_name:\nHook package id to extend AWS SAM CLI commands functionality. \n\nExample: `terraform` to extend AWS SAM CLI commands functionality to support terraform applications. \n\nAvailable Hook Names: ['terraform']\n* skip_prepare_infra:\nSkip preparation stage when there are no infrastructure changes. Only used in conjunction with --hook-name.\n* host:\nLocal hostname or IP address to bind to (default: '127.0.0.1')\n* port:\nLocal port number to listen on (default: '3001')\n* template_file:\nAWS SAM template which references built artifacts for resources in the template. (if applicable)\n* env_vars:\nJSON file containing values for Lambda function's environment variables.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* debug_port:\nWhen specified, Lambda function container will start in debug mode and will expose this port on localhost.\n* debugger_path:\nHost path to a debugger that will be mounted into the Lambda container.\n* debug_args:\nAdditional arguments to be passed to the debugger.\n* container_env_vars:\nJSON file containing additional environment variables to be set within the container when used in a debugging session locally.\n* docker_volume_basedir:\nSpecify the location basedir where the SAM template exists. If Docker is running on a remote machine, Path of the SAM template must be mounted on the Docker machine and modified to match the remote machine.\n* log_file:\nFile to capture output logs.\n* layer_cache_basedir:\nSpecify the location basedir where the lambda layers used by the template will be downloaded to.\n* skip_pull_image:\nSkip pulling down the latest Docker image for Lambda runtime.\n* docker_network:\nName or ID of an existing docker network for AWS Lambda docker containers to connect to, along with the default bridge network. If not specified, the Lambda containers will only connect to the default bridge docker network.\n* force_image_build:\nForce rebuilding the image used for invoking functions with layers.\n* warm_containers:\nOptional. Specifies how AWS SAM CLI manages \ncontainers for each function.\nTwo modes are available:\nEAGER: Containers for all functions are \nloaded at startup and persist between \ninvocations.\nLAZY: Containers are only loaded when each \nfunction is first invoked. Those containers \npersist for additional invocations.\n* debug_function:\nOptional. Specifies the Lambda Function logicalId to apply debug options to when --warm-containers is specified. This parameter applies to --debug-port, --debugger-path, and --debug-args.\n* shutdown:\nEmulate a shutdown event after invoke completes, to test extension handling of shutdown behavior.\n* container_host:\nHost of locally emulated Lambda container. This option is useful when the container runs on a different host than AWS SAM CLI. For example, if one wants to run AWS SAM CLI in a Docker container on macOS, this option could specify `host.docker.internal`\n* container_host_interface:\nIP address of the host network interface that container ports should bind to. Use 0.0.0.0 to bind to all interfaces.\n* add_host:\nPasses a hostname to IP address mapping to the Docker container's host file. This parameter can be passed multiple times.Example:--add-host example.com:127.0.0.1\n* invoke_image:\nContainer image URIs for invoking functions or starting api and function. One can specify the image URI used for the local function invocation (--invoke-image public.ecr.aws/sam/build-nodejs20.x:latest). One can also specify for each individual function with (--invoke-image Function1=public.ecr.aws/sam/build-nodejs20.x:latest). If a function does not have invoke image specified, the default AWS SAM CLI emulation image will be used.\n* no_memory_limit:\nRemoves the Memory limit during emulation. With this parameter, the underlying container will run without a --memory parameter\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.", + "description": "Available parameters for the local start lambda command:\n* terraform_plan_file:\nUsed for passing a custom plan file when executing the Terraform hook.\n* hook_name:\nHook package id to extend AWS SAM CLI commands functionality. \n\nExample: `terraform` to extend AWS SAM CLI commands functionality to support terraform applications. \n\nAvailable Hook Names: ['terraform']\n* skip_prepare_infra:\nSkip preparation stage when there are no infrastructure changes. Only used in conjunction with --hook-name.\n* host:\nLocal hostname or IP address to bind to (default: '127.0.0.1')\n* port:\nLocal port number to listen on (default: '3001')\n* template_file:\nAWS SAM template which references built artifacts for resources in the template. (if applicable)\n* env_vars:\nJSON file containing values for Lambda function's environment variables.\n* dotenv:\nPath to a .env file containing environment variables for Lambda functions. Variables defined here will apply to all functions. If both --env-vars and --dotenv are provided, variables from both will be merged with --env-vars taking precedence.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* debug_port:\nWhen specified, Lambda function container will start in debug mode and will expose this port on localhost.\n* debugger_path:\nHost path to a debugger that will be mounted into the Lambda container.\n* debug_args:\nAdditional arguments to be passed to the debugger.\n* container_env_vars:\nJSON file containing additional environment variables to be set within the container when used in a debugging session locally.\n* container_dotenv:\nPath to a .env file containing additional environment variables to be set within the container when used in a debugging session locally. If both --container-env-vars and --container-dotenv are provided, variables from both will be merged with --container-env-vars taking precedence.\n* docker_volume_basedir:\nSpecify the location basedir where the SAM template exists. If Docker is running on a remote machine, Path of the SAM template must be mounted on the Docker machine and modified to match the remote machine.\n* log_file:\nFile to capture output logs.\n* layer_cache_basedir:\nSpecify the location basedir where the lambda layers used by the template will be downloaded to.\n* skip_pull_image:\nSkip pulling down the latest Docker image for Lambda runtime.\n* docker_network:\nName or ID of an existing docker network for AWS Lambda docker containers to connect to, along with the default bridge network. If not specified, the Lambda containers will only connect to the default bridge docker network.\n* force_image_build:\nForce rebuilding the image used for invoking functions with layers.\n* warm_containers:\nOptional. Specifies how AWS SAM CLI manages \ncontainers for each function.\nTwo modes are available:\nEAGER: Containers for all functions are \nloaded at startup and persist between \ninvocations.\nLAZY: Containers are only loaded when each \nfunction is first invoked. Those containers \npersist for additional invocations.\n* debug_function:\nOptional. Specifies the Lambda Function logicalId to apply debug options to when --warm-containers is specified. This parameter applies to --debug-port, --debugger-path, and --debug-args.\n* shutdown:\nEmulate a shutdown event after invoke completes, to test extension handling of shutdown behavior.\n* container_host:\nHost of locally emulated Lambda container. This option is useful when the container runs on a different host than AWS SAM CLI. For example, if one wants to run AWS SAM CLI in a Docker container on macOS, this option could specify `host.docker.internal`\n* container_host_interface:\nIP address of the host network interface that container ports should bind to. Use 0.0.0.0 to bind to all interfaces.\n* add_host:\nPasses a hostname to IP address mapping to the Docker container's host file. This parameter can be passed multiple times.Example:--add-host example.com:127.0.0.1\n* invoke_image:\nContainer image URIs for invoking functions or starting api and function. One can specify the image URI used for the local function invocation (--invoke-image public.ecr.aws/sam/build-nodejs20.x:latest). One can also specify for each individual function with (--invoke-image Function1=public.ecr.aws/sam/build-nodejs20.x:latest). If a function does not have invoke image specified, the default AWS SAM CLI emulation image will be used.\n* no_memory_limit:\nRemoves the Memory limit during emulation. With this parameter, the underlying container will run without a --memory parameter\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.", "type": "object", "properties": { "terraform_plan_file": { @@ -883,6 +903,11 @@ "type": "string", "description": "JSON file containing values for Lambda function's environment variables." }, + "dotenv": { + "title": "dotenv", + "type": "string", + "description": "Path to a .env file containing environment variables for Lambda functions. Variables defined here will apply to all functions. If both --env-vars and --dotenv are provided, variables from both will be merged with --env-vars taking precedence." + }, "parameter_overrides": { "title": "parameter_overrides", "type": [ @@ -914,6 +939,11 @@ "type": "string", "description": "JSON file containing additional environment variables to be set within the container when used in a debugging session locally." }, + "container_dotenv": { + "title": "container_dotenv", + "type": "string", + "description": "Path to a .env file containing additional environment variables to be set within the container when used in a debugging session locally. If both --container-env-vars and --container-dotenv are provided, variables from both will be merged with --container-env-vars taking precedence." + }, "docker_volume_basedir": { "title": "docker_volume_basedir", "type": "string", diff --git a/tests/unit/commands/local/cli_common/test_invoke_context_dotenv.py b/tests/unit/commands/local/cli_common/test_invoke_context_dotenv.py new file mode 100644 index 0000000000..82f2ff0e46 --- /dev/null +++ b/tests/unit/commands/local/cli_common/test_invoke_context_dotenv.py @@ -0,0 +1,571 @@ +""" +Unit tests for .env file support in InvokeContext +""" + +import tempfile +import os +from unittest import TestCase +from unittest.mock import patch, Mock + +from samcli.commands.local.cli_common.invoke_context import InvokeContext + + +class TestInvokeContext_DotenvMerging(TestCase): + """Tests for merging .env files with JSON env vars""" + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext._get_container_manager") + @patch("samcli.commands.local.cli_common.invoke_context.SamLocalStackProvider") + @patch("samcli.commands.local.cli_common.invoke_context.SamFunctionProvider") + def test_dotenv_only(self, MockFunctionProvider, MockStackProvider, MockGetContainerManager): + """Should load only .env file when JSON is not provided""" + # Create .env file + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("VAR1=from_dotenv\n") + f.write("VAR2=also_from_dotenv\n") + dotenv_path = f.name + + # Create dummy template + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("AWSTemplateFormatVersion: '2010-09-09'\n") + template_path = f.name + + try: + # Mock stack provider + mock_stack = Mock() + MockStackProvider.get_stacks.return_value = ([mock_stack], Mock()) + + # Mock function provider + mock_function_provider = Mock() + mock_function_provider.get_all.return_value = [] + MockFunctionProvider.return_value = mock_function_provider + + # Mock container manager to avoid Docker checks + mock_container_manager = Mock() + mock_container_manager.is_docker_reachable = True + MockGetContainerManager.return_value = mock_container_manager + + with InvokeContext(template_file=template_path, dotenv_file=dotenv_path, env_vars_file=None) as context: + # Check that env vars were loaded correctly + self.assertIsNotNone(context._env_vars_value) + self.assertIn("Parameters", context._env_vars_value) + self.assertEqual(context._env_vars_value["Parameters"]["VAR1"], "from_dotenv") + self.assertEqual(context._env_vars_value["Parameters"]["VAR2"], "also_from_dotenv") + finally: + os.unlink(dotenv_path) + os.unlink(template_path) + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext._get_container_manager") + @patch("samcli.commands.local.cli_common.invoke_context.SamLocalStackProvider") + @patch("samcli.commands.local.cli_common.invoke_context.SamFunctionProvider") + def test_json_only(self, MockFunctionProvider, MockStackProvider, MockGetContainerManager): + """Should load only JSON file when .env is not provided""" + # Create JSON env vars file + import json + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump({"Parameters": {"VAR1": "from_json", "VAR2": "also_from_json"}}, f) + json_path = f.name + + # Create dummy template + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("AWSTemplateFormatVersion: '2010-09-09'\n") + template_path = f.name + + try: + # Mock stack provider + mock_stack = Mock() + MockStackProvider.get_stacks.return_value = ([mock_stack], Mock()) + + # Mock function provider + mock_function_provider = Mock() + mock_function_provider.get_all.return_value = [] + MockFunctionProvider.return_value = mock_function_provider + + # Mock container manager to avoid Docker checks + mock_container_manager = Mock() + mock_container_manager.is_docker_reachable = True + MockGetContainerManager.return_value = mock_container_manager + + with InvokeContext(template_file=template_path, dotenv_file=None, env_vars_file=json_path) as context: + # Check that env vars were loaded correctly + self.assertIsNotNone(context._env_vars_value) + self.assertIn("Parameters", context._env_vars_value) + self.assertEqual(context._env_vars_value["Parameters"]["VAR1"], "from_json") + self.assertEqual(context._env_vars_value["Parameters"]["VAR2"], "also_from_json") + finally: + os.unlink(json_path) + os.unlink(template_path) + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext._get_container_manager") + @patch("samcli.commands.local.cli_common.invoke_context.SamLocalStackProvider") + @patch("samcli.commands.local.cli_common.invoke_context.SamFunctionProvider") + def test_json_overrides_dotenv(self, MockFunctionProvider, MockStackProvider, MockGetContainerManager): + """Should have JSON values override .env values when both are provided""" + # Create .env file + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("VAR1=from_dotenv\n") + f.write("VAR2=also_from_dotenv\n") + f.write("VAR3=only_in_dotenv\n") + dotenv_path = f.name + + # Create JSON env vars file (overriding VAR1 and VAR2) + import json + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump({"Parameters": {"VAR1": "from_json_override", "VAR2": "also_from_json_override"}}, f) + json_path = f.name + + # Create dummy template + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("AWSTemplateFormatVersion: '2010-09-09'\n") + template_path = f.name + + try: + # Mock stack provider + mock_stack = Mock() + MockStackProvider.get_stacks.return_value = ([mock_stack], Mock()) + + # Mock function provider + mock_function_provider = Mock() + mock_function_provider.get_all.return_value = [] + MockFunctionProvider.return_value = mock_function_provider + + # Mock container manager to avoid Docker checks + mock_container_manager = Mock() + mock_container_manager.is_docker_reachable = True + MockGetContainerManager.return_value = mock_container_manager + + with InvokeContext( + template_file=template_path, dotenv_file=dotenv_path, env_vars_file=json_path + ) as context: + # Check that env vars were merged with JSON taking precedence + self.assertIsNotNone(context._env_vars_value) + self.assertIn("Parameters", context._env_vars_value) + + # JSON should override dotenv + self.assertEqual(context._env_vars_value["Parameters"]["VAR1"], "from_json_override") + self.assertEqual(context._env_vars_value["Parameters"]["VAR2"], "also_from_json_override") + + # Dotenv-only value should still be present + self.assertEqual(context._env_vars_value["Parameters"]["VAR3"], "only_in_dotenv") + finally: + os.unlink(dotenv_path) + os.unlink(json_path) + os.unlink(template_path) + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext._get_container_manager") + @patch("samcli.commands.local.cli_common.invoke_context.SamLocalStackProvider") + @patch("samcli.commands.local.cli_common.invoke_context.SamFunctionProvider") + def test_function_specific_overrides_preserved( + self, MockFunctionProvider, MockStackProvider, MockGetContainerManager + ): + """Should preserve function-specific overrides from JSON""" + # Create .env file + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("GLOBAL_VAR=from_dotenv\n") + dotenv_path = f.name + + # Create JSON with function-specific overrides + import json + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump( + { + "Parameters": {"GLOBAL_VAR": "global_override"}, + "MyFunction": {"FUNCTION_SPECIFIC": "function_value"}, + }, + f, + ) + json_path = f.name + + # Create dummy template + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("AWSTemplateFormatVersion: '2010-09-09'\n") + template_path = f.name + + try: + # Mock stack provider + mock_stack = Mock() + MockStackProvider.get_stacks.return_value = ([mock_stack], Mock()) + + # Mock function provider + mock_function_provider = Mock() + mock_function_provider.get_all.return_value = [] + MockFunctionProvider.return_value = mock_function_provider + + # Mock container manager to avoid Docker checks + mock_container_manager = Mock() + mock_container_manager.is_docker_reachable = True + MockGetContainerManager.return_value = mock_container_manager + + with InvokeContext( + template_file=template_path, dotenv_file=dotenv_path, env_vars_file=json_path + ) as context: + # Check that both global and function-specific overrides are preserved + self.assertIsNotNone(context._env_vars_value) + self.assertIn("Parameters", context._env_vars_value) + self.assertIn("MyFunction", context._env_vars_value) + + self.assertEqual(context._env_vars_value["Parameters"]["GLOBAL_VAR"], "global_override") + self.assertEqual(context._env_vars_value["MyFunction"]["FUNCTION_SPECIFIC"], "function_value") + finally: + os.unlink(dotenv_path) + os.unlink(json_path) + os.unlink(template_path) + + +class TestInvokeContext_ContainerDotenvMerging(TestCase): + """Tests for container dotenv functionality""" + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext._get_container_manager") + @patch("samcli.commands.local.cli_common.invoke_context.SamLocalStackProvider") + @patch("samcli.commands.local.cli_common.invoke_context.SamFunctionProvider") + def test_container_dotenv_only(self, MockFunctionProvider, MockStackProvider, MockGetContainerManager): + """Should load container env vars from .env file only""" + # Create container .env file + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("DEBUG_VAR=debug_value\n") + f.write("CONTAINER_VAR=container_value\n") + container_dotenv_path = f.name + + # Create dummy template + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("AWSTemplateFormatVersion: '2010-09-09'\n") + template_path = f.name + + try: + # Mock stack provider + mock_stack = Mock() + MockStackProvider.get_stacks.return_value = ([mock_stack], Mock()) + + # Mock function provider + mock_function_provider = Mock() + mock_function_provider.get_all.return_value = [] + MockFunctionProvider.return_value = mock_function_provider + + # Mock container manager to avoid Docker checks + mock_container_manager = Mock() + mock_container_manager.is_docker_reachable = True + MockGetContainerManager.return_value = mock_container_manager + + with InvokeContext( + template_file=template_path, container_dotenv_file=container_dotenv_path, container_env_vars_file=None + ) as context: + # Check that container env vars were loaded correctly + # Container env vars should remain flat (not wrapped in Parameters) + self.assertIsNotNone(context._container_env_vars_value) + self.assertEqual(context._container_env_vars_value["DEBUG_VAR"], "debug_value") + self.assertEqual(context._container_env_vars_value["CONTAINER_VAR"], "container_value") + # Should NOT have Parameters wrapper + self.assertNotIn("Parameters", context._container_env_vars_value) + finally: + os.unlink(container_dotenv_path) + os.unlink(template_path) + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext._get_container_manager") + @patch("samcli.commands.local.cli_common.invoke_context.SamLocalStackProvider") + @patch("samcli.commands.local.cli_common.invoke_context.SamFunctionProvider") + def test_container_json_overrides_container_dotenv( + self, MockFunctionProvider, MockStackProvider, MockGetContainerManager + ): + """Should have container JSON values override container .env values""" + # Create container .env file + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("VAR1=from_container_dotenv\n") + f.write("VAR2=also_from_container_dotenv\n") + f.write("VAR3=only_in_container_dotenv\n") + container_dotenv_path = f.name + + # Create container JSON env vars file (overriding VAR1 and VAR2) + import json + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump({"VAR1": "from_container_json", "VAR2": "also_from_container_json"}, f) + container_json_path = f.name + + # Create dummy template + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("AWSTemplateFormatVersion: '2010-09-09'\n") + template_path = f.name + + try: + # Mock stack provider + mock_stack = Mock() + MockStackProvider.get_stacks.return_value = ([mock_stack], Mock()) + + # Mock function provider + mock_function_provider = Mock() + mock_function_provider.get_all.return_value = [] + MockFunctionProvider.return_value = mock_function_provider + + # Mock container manager to avoid Docker checks + mock_container_manager = Mock() + mock_container_manager.is_docker_reachable = True + MockGetContainerManager.return_value = mock_container_manager + + with InvokeContext( + template_file=template_path, + container_dotenv_file=container_dotenv_path, + container_env_vars_file=container_json_path, + ) as context: + # Check that container env vars were merged with JSON taking precedence + self.assertIsNotNone(context._container_env_vars_value) + + # JSON should override dotenv + self.assertEqual(context._container_env_vars_value["VAR1"], "from_container_json") + self.assertEqual(context._container_env_vars_value["VAR2"], "also_from_container_json") + + # Dotenv-only value should still be present + self.assertEqual(context._container_env_vars_value["VAR3"], "only_in_container_dotenv") + + # Should remain flat (no Parameters wrapper) + self.assertNotIn("Parameters", context._container_env_vars_value) + finally: + os.unlink(container_dotenv_path) + os.unlink(container_json_path) + os.unlink(template_path) + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext._get_container_manager") + @patch("samcli.commands.local.cli_common.invoke_context.SamLocalStackProvider") + @patch("samcli.commands.local.cli_common.invoke_context.SamFunctionProvider") + def test_dotenv_and_container_dotenv_independent( + self, MockFunctionProvider, MockStackProvider, MockGetContainerManager + ): + """Should handle both regular dotenv and container dotenv independently""" + # Create regular .env file + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("LAMBDA_VAR=lambda_value\n") + dotenv_path = f.name + + # Create container .env file + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("DEBUG_VAR=debug_value\n") + container_dotenv_path = f.name + + # Create dummy template + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("AWSTemplateFormatVersion: '2010-09-09'\n") + template_path = f.name + + try: + # Mock stack provider + mock_stack = Mock() + MockStackProvider.get_stacks.return_value = ([mock_stack], Mock()) + + # Mock function provider + mock_function_provider = Mock() + mock_function_provider.get_all.return_value = [] + MockFunctionProvider.return_value = mock_function_provider + + # Mock container manager to avoid Docker checks + mock_container_manager = Mock() + mock_container_manager.is_docker_reachable = True + MockGetContainerManager.return_value = mock_container_manager + + with InvokeContext( + template_file=template_path, dotenv_file=dotenv_path, container_dotenv_file=container_dotenv_path + ) as context: + # Check that regular env vars are wrapped in Parameters + self.assertIsNotNone(context._env_vars_value) + self.assertIn("Parameters", context._env_vars_value) + self.assertEqual(context._env_vars_value["Parameters"]["LAMBDA_VAR"], "lambda_value") + + # Check that container env vars are flat (not wrapped) + self.assertIsNotNone(context._container_env_vars_value) + self.assertEqual(context._container_env_vars_value["DEBUG_VAR"], "debug_value") + self.assertNotIn("Parameters", context._container_env_vars_value) + finally: + os.unlink(dotenv_path) + os.unlink(container_dotenv_path) + os.unlink(template_path) + + +class TestInvokeContext_DotenvErrorHandling(TestCase): + """Tests for error handling and edge cases with .env files""" + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext._get_container_manager") + @patch("samcli.commands.local.cli_common.invoke_context.SamLocalStackProvider") + @patch("samcli.commands.local.cli_common.invoke_context.SamFunctionProvider") + def test_non_existent_dotenv_file_raises_exception( + self, MockFunctionProvider, MockStackProvider, MockGetContainerManager + ): + """Should raise exception when .env file doesn't exist""" + from samcli.commands.local.cli_common.invoke_context import InvalidEnvironmentVariablesFileException + + # Create dummy template + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("AWSTemplateFormatVersion: '2010-09-09'\n") + template_path = f.name + + try: + # Mock stack provider + mock_stack = Mock() + MockStackProvider.get_stacks.return_value = ([mock_stack], Mock()) + + # Mock function provider + mock_function_provider = Mock() + mock_function_provider.get_all.return_value = [] + MockFunctionProvider.return_value = mock_function_provider + + # Mock container manager + mock_container_manager = Mock() + mock_container_manager.is_docker_reachable = True + MockGetContainerManager.return_value = mock_container_manager + + # Attempt to use non-existent .env file + with self.assertRaises(InvalidEnvironmentVariablesFileException) as context: + with InvokeContext( + template_file=template_path, dotenv_file="/nonexistent/path/.env", env_vars_file=None + ): + pass + + self.assertIn("not found", str(context.exception)) + finally: + os.unlink(template_path) + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext._get_container_manager") + @patch("samcli.commands.local.cli_common.invoke_context.SamLocalStackProvider") + @patch("samcli.commands.local.cli_common.invoke_context.SamFunctionProvider") + def test_empty_dotenv_file_logs_warning(self, MockFunctionProvider, MockStackProvider, MockGetContainerManager): + """Should log warning when .env file is empty""" + # Create empty .env file + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + # Write nothing - empty file + dotenv_path = f.name + + # Create dummy template + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("AWSTemplateFormatVersion: '2010-09-09'\n") + template_path = f.name + + try: + # Mock stack provider + mock_stack = Mock() + MockStackProvider.get_stacks.return_value = ([mock_stack], Mock()) + + # Mock function provider + mock_function_provider = Mock() + mock_function_provider.get_all.return_value = [] + MockFunctionProvider.return_value = mock_function_provider + + # Mock container manager + mock_container_manager = Mock() + mock_container_manager.is_docker_reachable = True + MockGetContainerManager.return_value = mock_container_manager + + with InvokeContext(template_file=template_path, dotenv_file=dotenv_path, env_vars_file=None) as context: + # Empty file should result in None or empty Parameters + self.assertTrue(context._env_vars_value is None or context._env_vars_value.get("Parameters", {}) == {}) + finally: + os.unlink(dotenv_path) + os.unlink(template_path) + + def test_get_dotenv_values_static_method_nonexistent_file(self): + """Test that _get_dotenv_values raises exception for non-existent file""" + from samcli.commands.local.cli_common.invoke_context import ( + InvokeContext, + InvalidEnvironmentVariablesFileException, + ) + + with self.assertRaises(InvalidEnvironmentVariablesFileException) as context: + InvokeContext._get_dotenv_values("/path/that/does/not/exist/.env") + + self.assertIn("not found", str(context.exception)) + + def test_get_dotenv_values_static_method_returns_none_for_none_input(self): + """Test that _get_dotenv_values returns None when filename is None""" + from samcli.commands.local.cli_common.invoke_context import InvokeContext + + result = InvokeContext._get_dotenv_values(None) + self.assertIsNone(result) + + +class TestInvokeContext_MergeEnvVarsDirectTesting(TestCase): + """Direct tests of _merge_env_vars without mocking - tests actual behavior""" + + def test_merge_with_both_none_returns_none(self): + """Test that merging two None values returns None""" + from samcli.commands.local.cli_common.invoke_context import InvokeContext + + result = InvokeContext._merge_env_vars(None, None, wrap_in_parameters=True) + self.assertIsNone(result) + + def test_merge_dotenv_only_with_parameters_wrapping(self): + """Test merging with only dotenv vars and Parameters wrapping""" + from samcli.commands.local.cli_common.invoke_context import InvokeContext + + dotenv = {"VAR1": "value1", "VAR2": "value2"} + result = InvokeContext._merge_env_vars(dotenv, None, wrap_in_parameters=True) + + self.assertIsNotNone(result) + self.assertIn("Parameters", result) + self.assertEqual(result["Parameters"]["VAR1"], "value1") + self.assertEqual(result["Parameters"]["VAR2"], "value2") + + def test_merge_dotenv_only_without_parameters_wrapping(self): + """Test merging with only dotenv vars without Parameters wrapping (container mode)""" + from samcli.commands.local.cli_common.invoke_context import InvokeContext + + dotenv = {"VAR1": "value1", "VAR2": "value2"} + result = InvokeContext._merge_env_vars(dotenv, None, wrap_in_parameters=False) + + self.assertIsNotNone(result) + self.assertNotIn("Parameters", result) + self.assertEqual(result["VAR1"], "value1") + self.assertEqual(result["VAR2"], "value2") + + def test_merge_json_only_with_parameters(self): + """Test merging with only JSON vars that have Parameters section""" + from samcli.commands.local.cli_common.invoke_context import InvokeContext + + json_vars = {"Parameters": {"VAR1": "json_value1", "VAR2": "json_value2"}} + result = InvokeContext._merge_env_vars(None, json_vars, wrap_in_parameters=True) + + self.assertIsNotNone(result) + self.assertIn("Parameters", result) + self.assertEqual(result["Parameters"]["VAR1"], "json_value1") + self.assertEqual(result["Parameters"]["VAR2"], "json_value2") + + def test_merge_json_overrides_dotenv_in_parameters(self): + """Test that JSON values override dotenv values in Parameters section""" + from samcli.commands.local.cli_common.invoke_context import InvokeContext + + dotenv = {"VAR1": "dotenv_value", "VAR2": "dotenv_value2", "VAR3": "only_dotenv"} + json_vars = {"Parameters": {"VAR1": "json_override", "VAR2": "json_override2"}} + result = InvokeContext._merge_env_vars(dotenv, json_vars, wrap_in_parameters=True) + + self.assertIsNotNone(result) + self.assertEqual(result["Parameters"]["VAR1"], "json_override") # JSON wins + self.assertEqual(result["Parameters"]["VAR2"], "json_override2") # JSON wins + self.assertEqual(result["Parameters"]["VAR3"], "only_dotenv") # Dotenv preserved + + def test_merge_preserves_function_specific_overrides(self): + """Test that function-specific JSON overrides are preserved alongside Parameters""" + from samcli.commands.local.cli_common.invoke_context import InvokeContext + + dotenv = {"GLOBAL": "dotenv_value"} + json_vars = { + "Parameters": {"GLOBAL": "json_override"}, + "MyFunction": {"FUNC_VAR": "func_value"}, + } + result = InvokeContext._merge_env_vars(dotenv, json_vars, wrap_in_parameters=True) + + self.assertIsNotNone(result) + self.assertIn("Parameters", result) + self.assertIn("MyFunction", result) + self.assertEqual(result["Parameters"]["GLOBAL"], "json_override") + self.assertEqual(result["MyFunction"]["FUNC_VAR"], "func_value") + + def test_merge_container_vars_without_parameters(self): + """Test merging container vars (flat structure, no Parameters wrapping)""" + from samcli.commands.local.cli_common.invoke_context import InvokeContext + + dotenv = {"DEBUG_VAR1": "debug1", "DEBUG_VAR2": "debug2"} + json_vars = {"DEBUG_VAR1": "json_override", "DEBUG_VAR3": "json_only"} + result = InvokeContext._merge_env_vars(dotenv, json_vars, wrap_in_parameters=False) + + self.assertIsNotNone(result) + self.assertNotIn("Parameters", result) # Should be flat + self.assertEqual(result["DEBUG_VAR1"], "json_override") # JSON wins + self.assertEqual(result["DEBUG_VAR2"], "debug2") # Dotenv preserved + self.assertEqual(result["DEBUG_VAR3"], "json_only") # JSON added diff --git a/tests/unit/commands/local/cli_common/test_invoke_context_function_specific.py b/tests/unit/commands/local/cli_common/test_invoke_context_function_specific.py new file mode 100644 index 0000000000..ec0255a6e4 --- /dev/null +++ b/tests/unit/commands/local/cli_common/test_invoke_context_function_specific.py @@ -0,0 +1,377 @@ +""" +Unit tests for function-specific environment variables in .env files +""" + +import tempfile +import os +from unittest import TestCase +from unittest.mock import patch, Mock + +from samcli.commands.local.cli_common.invoke_context import InvokeContext + + +class TestParseFunctionSpecificEnvVars(TestCase): + """Tests for _parse_function_specific_env_vars() method""" + + def test_parse_function_specific_basic(self): + """Test basic function-specific parsing with asterisk separator""" + env_dict = { + "GLOBAL_VAR": "global_value", + "MyFunction*API_KEY": "function_key", + "MyFunction*TIMEOUT": "30", + } + + result = InvokeContext._parse_function_specific_env_vars(env_dict) + + self.assertIn("Parameters", result) + self.assertIn("MyFunction", result) + self.assertEqual(result["Parameters"]["GLOBAL_VAR"], "global_value") + self.assertEqual(result["MyFunction"]["API_KEY"], "function_key") + self.assertEqual(result["MyFunction"]["TIMEOUT"], "30") + + def test_parse_all_caps_as_global(self): + """Test that ALL_CAPS variables remain global""" + env_dict = { + "LAMBDA_VAR": "lambda_value", + "AWS_REGION": "us-east-1", + "API_KEY": "global_key", + } + + result = InvokeContext._parse_function_specific_env_vars(env_dict) + + self.assertIn("Parameters", result) + self.assertEqual(result["Parameters"]["LAMBDA_VAR"], "lambda_value") + self.assertEqual(result["Parameters"]["AWS_REGION"], "us-east-1") + self.assertEqual(result["Parameters"]["API_KEY"], "global_key") + # No function-specific sections should be created + self.assertEqual(len(result), 1) # Only Parameters + + def test_parse_lowercase_as_global(self): + """Test that lowercase_with_underscores remain global""" + env_dict = { + "database_url": "postgres://localhost", + "api_version": "v1", + } + + result = InvokeContext._parse_function_specific_env_vars(env_dict) + + self.assertIn("Parameters", result) + self.assertEqual(result["Parameters"]["database_url"], "postgres://localhost") + self.assertEqual(result["Parameters"]["api_version"], "v1") + self.assertEqual(len(result), 1) # Only Parameters + + def test_parse_any_naming_convention_with_asterisk(self): + """Test that any naming convention works with asterisk separator""" + env_dict = { + "MyFunction*VAR1": "value1", # PascalCase + "myFunction*VAR2": "value2", # camelCase + "my_function*VAR3": "value3", # snake_case + "MYFUNCTION*VAR4": "value4", # UPPERCASE + } + + result = InvokeContext._parse_function_specific_env_vars(env_dict) + + self.assertIn("MyFunction", result) + self.assertIn("myFunction", result) + self.assertIn("my_function", result) + self.assertIn("MYFUNCTION", result) + self.assertEqual(result["MyFunction"]["VAR1"], "value1") + self.assertEqual(result["myFunction"]["VAR2"], "value2") + self.assertEqual(result["my_function"]["VAR3"], "value3") + self.assertEqual(result["MYFUNCTION"]["VAR4"], "value4") + + def test_parse_mixed_variables(self): + """Test parsing mixed global and function-specific variables""" + env_dict = { + "DATABASE_URL": "postgres://localhost", + "MyFunction*API_KEY": "func_key", + "LAMBDA_RUNTIME": "python3.11", + "HelloWorld*TIMEOUT": "30", + "API_VERSION": "v1", + "MY_FUNCTION_VAR": "underscore_is_global", # Underscore without asterisk = global + } + + result = InvokeContext._parse_function_specific_env_vars(env_dict) + + # Check global vars + self.assertEqual(result["Parameters"]["DATABASE_URL"], "postgres://localhost") + self.assertEqual(result["Parameters"]["LAMBDA_RUNTIME"], "python3.11") + self.assertEqual(result["Parameters"]["API_VERSION"], "v1") + self.assertEqual(result["Parameters"]["MY_FUNCTION_VAR"], "underscore_is_global") + + # Check function-specific vars + self.assertEqual(result["MyFunction"]["API_KEY"], "func_key") + self.assertEqual(result["HelloWorld"]["TIMEOUT"], "30") + + def test_parse_no_underscore_is_global(self): + """Test that variables without underscores are global""" + env_dict = { + "APIKEY": "key123", + "DatabaseURL": "postgres://localhost", + "timeout": "30", + } + + result = InvokeContext._parse_function_specific_env_vars(env_dict) + + self.assertIn("Parameters", result) + self.assertEqual(result["Parameters"]["APIKEY"], "key123") + self.assertEqual(result["Parameters"]["DatabaseURL"], "postgres://localhost") + self.assertEqual(result["Parameters"]["timeout"], "30") + self.assertEqual(len(result), 1) # Only Parameters + + def test_parse_multiple_vars_same_function(self): + """Test multiple variables for the same function""" + env_dict = { + "MyFunction*VAR1": "value1", + "MyFunction*VAR2": "value2", + "MyFunction*VAR3": "value3", + } + + result = InvokeContext._parse_function_specific_env_vars(env_dict) + + self.assertIn("MyFunction", result) + self.assertEqual(len(result["MyFunction"]), 3) + self.assertEqual(result["MyFunction"]["VAR1"], "value1") + self.assertEqual(result["MyFunction"]["VAR2"], "value2") + self.assertEqual(result["MyFunction"]["VAR3"], "value3") + + +class TestGetDotenvValuesWithParsing(TestCase): + """Tests for _get_dotenv_values() with parse_function_specific parameter""" + + def test_parse_function_specific_false_returns_flat(self): + """Test that parse_function_specific=False returns flat structure""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("GLOBAL_VAR=global\n") + f.write("MyFunction*API_KEY=function_key\n") + dotenv_path = f.name + + try: + result = InvokeContext._get_dotenv_values(dotenv_path, parse_function_specific=False) + + # Should be flat + self.assertNotIn("Parameters", result) + self.assertNotIn("MyFunction", result) + self.assertEqual(result["GLOBAL_VAR"], "global") + self.assertEqual(result["MyFunction*API_KEY"], "function_key") + finally: + os.unlink(dotenv_path) + + def test_parse_function_specific_true_returns_hierarchical(self): + """Test that parse_function_specific=True returns hierarchical structure""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("GLOBAL_VAR=global\n") + f.write("MyFunction*API_KEY=function_key\n") + dotenv_path = f.name + + try: + result = InvokeContext._get_dotenv_values(dotenv_path, parse_function_specific=True) + + # Should be hierarchical + self.assertIn("Parameters", result) + self.assertIn("MyFunction", result) + self.assertEqual(result["Parameters"]["GLOBAL_VAR"], "global") + self.assertEqual(result["MyFunction"]["API_KEY"], "function_key") + finally: + os.unlink(dotenv_path) + + +class TestInvokeContextWithFunctionSpecific(TestCase): + """Integration tests for InvokeContext with function-specific env vars""" + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext._get_container_manager") + @patch("samcli.commands.local.cli_common.invoke_context.SamLocalStackProvider") + @patch("samcli.commands.local.cli_common.invoke_context.SamFunctionProvider") + def test_dotenv_with_function_specific_vars(self, MockFunctionProvider, MockStackProvider, MockGetContainerManager): + """Test that function-specific vars from .env are properly loaded""" + # Create .env file with function-specific vars + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("DATABASE_URL=postgres://localhost\n") + f.write("MyFunction*API_KEY=my_function_key\n") + f.write("MyFunction*DEBUG=true\n") + f.write("HelloWorld*TIMEOUT=30\n") + dotenv_path = f.name + + # Create dummy template + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("AWSTemplateFormatVersion: '2010-09-09'\n") + template_path = f.name + + try: + # Mock stack provider + mock_stack = Mock() + MockStackProvider.get_stacks.return_value = ([mock_stack], Mock()) + + # Mock function provider + mock_function_provider = Mock() + mock_function_provider.get_all.return_value = [] + MockFunctionProvider.return_value = mock_function_provider + + # Mock container manager + mock_container_manager = Mock() + mock_container_manager.is_docker_reachable = True + MockGetContainerManager.return_value = mock_container_manager + + with InvokeContext(template_file=template_path, dotenv_file=dotenv_path) as context: + # Check hierarchical structure + self.assertIsNotNone(context._env_vars_value) + self.assertIn("Parameters", context._env_vars_value) + self.assertIn("MyFunction", context._env_vars_value) + self.assertIn("HelloWorld", context._env_vars_value) + + # Check global vars + self.assertEqual(context._env_vars_value["Parameters"]["DATABASE_URL"], "postgres://localhost") + + # Check function-specific vars + self.assertEqual(context._env_vars_value["MyFunction"]["API_KEY"], "my_function_key") + self.assertEqual(context._env_vars_value["MyFunction"]["DEBUG"], "true") + self.assertEqual(context._env_vars_value["HelloWorld"]["TIMEOUT"], "30") + finally: + os.unlink(dotenv_path) + os.unlink(template_path) + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext._get_container_manager") + @patch("samcli.commands.local.cli_common.invoke_context.SamLocalStackProvider") + @patch("samcli.commands.local.cli_common.invoke_context.SamFunctionProvider") + def test_json_overrides_function_specific_dotenv( + self, MockFunctionProvider, MockStackProvider, MockGetContainerManager + ): + """Test that JSON overrides function-specific vars from .env""" + import json + + # Create .env file + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("DATABASE_URL=from_dotenv\n") + f.write("MyFunction*API_KEY=dotenv_key\n") + f.write("MyFunction*DEBUG=true\n") + dotenv_path = f.name + + # Create JSON file with overrides + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump( + { + "Parameters": {"DATABASE_URL": "from_json"}, + "MyFunction": {"API_KEY": "json_key"}, # Override function-specific + }, + f, + ) + json_path = f.name + + # Create dummy template + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("AWSTemplateFormatVersion: '2010-09-09'\n") + template_path = f.name + + try: + # Mock stack provider + mock_stack = Mock() + MockStackProvider.get_stacks.return_value = ([mock_stack], Mock()) + + # Mock function provider + mock_function_provider = Mock() + mock_function_provider.get_all.return_value = [] + MockFunctionProvider.return_value = mock_function_provider + + # Mock container manager + mock_container_manager = Mock() + mock_container_manager.is_docker_reachable = True + MockGetContainerManager.return_value = mock_container_manager + + with InvokeContext( + template_file=template_path, dotenv_file=dotenv_path, env_vars_file=json_path + ) as context: + # Check that JSON overrode global var + self.assertEqual(context._env_vars_value["Parameters"]["DATABASE_URL"], "from_json") + + # Check that JSON overrode function-specific var + self.assertEqual(context._env_vars_value["MyFunction"]["API_KEY"], "json_key") + + # Check that non-overridden function-specific var remains + self.assertEqual(context._env_vars_value["MyFunction"]["DEBUG"], "true") + finally: + os.unlink(dotenv_path) + os.unlink(json_path) + os.unlink(template_path) + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext._get_container_manager") + @patch("samcli.commands.local.cli_common.invoke_context.SamLocalStackProvider") + @patch("samcli.commands.local.cli_common.invoke_context.SamFunctionProvider") + def test_container_dotenv_remains_flat(self, MockFunctionProvider, MockStackProvider, MockGetContainerManager): + """Test that container dotenv doesn't parse function-specific vars""" + # Create container .env file with would-be function-specific vars + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("DEBUG_VAR=debug\n") + f.write("MyFunction*API_KEY=key\n") # Should stay flat (not parsed for containers) + container_dotenv_path = f.name + + # Create dummy template + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("AWSTemplateFormatVersion: '2010-09-09'\n") + template_path = f.name + + try: + # Mock stack provider + mock_stack = Mock() + MockStackProvider.get_stacks.return_value = ([mock_stack], Mock()) + + # Mock function provider + mock_function_provider = Mock() + mock_function_provider.get_all.return_value = [] + MockFunctionProvider.return_value = mock_function_provider + + # Mock container manager + mock_container_manager = Mock() + mock_container_manager.is_docker_reachable = True + MockGetContainerManager.return_value = mock_container_manager + + with InvokeContext(template_file=template_path, container_dotenv_file=container_dotenv_path) as context: + # Container env vars should remain flat + self.assertIsNotNone(context._container_env_vars_value) + self.assertNotIn("Parameters", context._container_env_vars_value) + self.assertNotIn("MyFunction", context._container_env_vars_value) + + # Variables should be in flat structure + self.assertEqual(context._container_env_vars_value["DEBUG_VAR"], "debug") + self.assertEqual(context._container_env_vars_value["MyFunction*API_KEY"], "key") + finally: + os.unlink(container_dotenv_path) + os.unlink(template_path) + + +class TestMergeEnvVarsWithHierarchical(TestCase): + """Tests for _merge_env_vars with hierarchical dotenv structures""" + + def test_merge_hierarchical_dotenv_with_json(self): + """Test merging hierarchical dotenv with JSON""" + dotenv_vars = { + "Parameters": {"GLOBAL1": "dotenv_global1", "GLOBAL2": "dotenv_global2"}, + "MyFunction": {"VAR1": "dotenv_func1"}, + } + json_vars = { + "Parameters": {"GLOBAL1": "json_global1"}, # Override + "MyFunction": {"VAR2": "json_func2"}, # Add new + } + + result = InvokeContext._merge_env_vars(dotenv_vars, json_vars, wrap_in_parameters=True) + + # Check global vars merge + self.assertEqual(result["Parameters"]["GLOBAL1"], "json_global1") # JSON wins + self.assertEqual(result["Parameters"]["GLOBAL2"], "dotenv_global2") # Preserved + + # Check function-specific merge + self.assertEqual(result["MyFunction"]["VAR1"], "dotenv_func1") # Preserved + self.assertEqual(result["MyFunction"]["VAR2"], "json_func2") # Added + + def test_merge_hierarchical_dotenv_only(self): + """Test hierarchical dotenv without JSON""" + dotenv_vars = { + "Parameters": {"GLOBAL": "value"}, + "MyFunction": {"VAR": "func_value"}, + } + + result = InvokeContext._merge_env_vars(dotenv_vars, None, wrap_in_parameters=True) + + self.assertIn("Parameters", result) + self.assertIn("MyFunction", result) + self.assertEqual(result["Parameters"]["GLOBAL"], "value") + self.assertEqual(result["MyFunction"]["VAR"], "func_value") diff --git a/tests/unit/commands/local/invoke/test_cli.py b/tests/unit/commands/local/invoke/test_cli.py index d18eae519e..4be3809b4e 100644 --- a/tests/unit/commands/local/invoke/test_cli.py +++ b/tests/unit/commands/local/invoke/test_cli.py @@ -30,7 +30,9 @@ def setUp(self): self.template = "template" self.eventfile = "eventfile" self.env_vars = "env-vars" + self.dotenv = None self.container_env_vars = "debug-env-vars" + self.container_dotenv = None self.debug_ports = [123] self.debug_args = "args" self.debugger_path = "/test/path" @@ -66,10 +68,12 @@ def call_cli(self): event=self.eventfile, no_event=self.no_event, env_vars=self.env_vars, + dotenv=self.dotenv, debug_port=self.debug_ports, debug_args=self.debug_args, debugger_path=self.debugger_path, container_env_vars=self.container_env_vars, + container_dotenv=self.container_dotenv, docker_volume_basedir=self.docker_volume_basedir, docker_network=self.docker_network, log_file=self.log_file, @@ -104,6 +108,7 @@ def test_cli_must_setup_context_and_invoke(self, get_event_mock, InvokeContextMo template_file=self.template, function_identifier=self.function_id, env_vars_file=self.env_vars, + dotenv_file=self.dotenv, docker_volume_basedir=self.docker_volume_basedir, docker_network=self.docker_network, log_file=self.log_file, @@ -112,6 +117,7 @@ def test_cli_must_setup_context_and_invoke(self, get_event_mock, InvokeContextMo debug_args=self.debug_args, debugger_path=self.debugger_path, container_env_vars_file=self.container_env_vars, + container_dotenv_file=self.container_dotenv, parameter_overrides=self.parameter_overrides, layer_cache_basedir=self.layer_cache_basedir, force_image_build=self.force_image_build, @@ -150,6 +156,7 @@ def test_cli_must_invoke_with_no_event(self, get_event_mock, InvokeContextMock): template_file=self.template, function_identifier=self.function_id, env_vars_file=self.env_vars, + dotenv_file=self.dotenv, docker_volume_basedir=self.docker_volume_basedir, docker_network=self.docker_network, log_file=self.log_file, @@ -158,6 +165,7 @@ def test_cli_must_invoke_with_no_event(self, get_event_mock, InvokeContextMock): debug_args=self.debug_args, debugger_path=self.debugger_path, container_env_vars_file=self.container_env_vars, + container_dotenv_file=self.container_dotenv, parameter_overrides=self.parameter_overrides, layer_cache_basedir=self.layer_cache_basedir, force_image_build=self.force_image_build, @@ -312,6 +320,195 @@ def test_must_raise_user_exception_on_function_no_free_ports( self.assertEqual(msg, expected_exception_message) +class TestCliWithDotenvFiles(TestCase): + """Tests for CLI with .env file support - demonstrates proper testing with real file paths""" + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext") + @patch("samcli.commands.local.invoke.cli._get_event") + def test_cli_with_dotenv_file(self, get_event_mock, InvokeContextMock): + """Test that --dotenv parameter is properly passed to InvokeContext""" + import tempfile + import os + + # Create a temporary .env file + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("TEST_VAR=test_value\n") + dotenv_path = f.name + + try: + event_data = "data" + get_event_mock.return_value = event_data + + # Mock the context manager + context_mock = Mock() + InvokeContextMock.return_value.__enter__.return_value = context_mock + + # Call CLI with dotenv file + invoke_cli( + ctx=Mock(region="us-east-1", profile=None), + function_identifier="test-function", + template="template.yaml", + event=None, + no_event=True, + env_vars=None, + dotenv=dotenv_path, # Using actual file path instead of None + debug_port=None, + debug_args=None, + debugger_path=None, + container_env_vars=None, + container_dotenv=None, + docker_volume_basedir=None, + docker_network=None, + log_file=None, + skip_pull_image=True, + parameter_overrides={}, + layer_cache_basedir=None, + force_image_build=False, + shutdown=False, + container_host="localhost", + container_host_interface="127.0.0.1", + add_host=None, + invoke_image=None, + hook_name=None, + runtime=None, + mount_symlinks=False, + no_mem_limit=False, + ) + + # Verify InvokeContext was called with the dotenv file path + InvokeContextMock.assert_called_once() + call_kwargs = InvokeContextMock.call_args[1] + self.assertEqual(call_kwargs["dotenv_file"], dotenv_path) + self.assertIsNone(call_kwargs["env_vars_file"]) + finally: + os.unlink(dotenv_path) + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext") + @patch("samcli.commands.local.invoke.cli._get_event") + def test_cli_with_container_dotenv_file(self, get_event_mock, InvokeContextMock): + """Test that --container-dotenv parameter is properly passed to InvokeContext""" + import tempfile + import os + + # Create a temporary container .env file + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("DEBUG_VAR=debug_value\n") + container_dotenv_path = f.name + + try: + event_data = "data" + get_event_mock.return_value = event_data + + # Mock the context manager + context_mock = Mock() + InvokeContextMock.return_value.__enter__.return_value = context_mock + + # Call CLI with container dotenv file + invoke_cli( + ctx=Mock(region="us-east-1", profile=None), + function_identifier="test-function", + template="template.yaml", + event=None, + no_event=True, + env_vars=None, + dotenv=None, + debug_port=[5858], + debug_args=None, + debugger_path=None, + container_env_vars=None, + container_dotenv=container_dotenv_path, # Using actual file path instead of None + docker_volume_basedir=None, + docker_network=None, + log_file=None, + skip_pull_image=True, + parameter_overrides={}, + layer_cache_basedir=None, + force_image_build=False, + shutdown=False, + container_host="localhost", + container_host_interface="127.0.0.1", + add_host=None, + invoke_image=None, + hook_name=None, + runtime=None, + mount_symlinks=False, + no_mem_limit=False, + ) + + # Verify InvokeContext was called with the container dotenv file path + InvokeContextMock.assert_called_once() + call_kwargs = InvokeContextMock.call_args[1] + self.assertEqual(call_kwargs["container_dotenv_file"], container_dotenv_path) + self.assertIsNone(call_kwargs["dotenv_file"]) + finally: + os.unlink(container_dotenv_path) + + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext") + @patch("samcli.commands.local.invoke.cli._get_event") + def test_cli_with_both_dotenv_files(self, get_event_mock, InvokeContextMock): + """Test that both --dotenv and --container-dotenv can be used together""" + import tempfile + import os + + # Create temporary .env files + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("LAMBDA_VAR=lambda_value\n") + dotenv_path = f.name + + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("DEBUG_VAR=debug_value\n") + container_dotenv_path = f.name + + try: + event_data = "data" + get_event_mock.return_value = event_data + + # Mock the context manager + context_mock = Mock() + InvokeContextMock.return_value.__enter__.return_value = context_mock + + # Call CLI with both dotenv files + invoke_cli( + ctx=Mock(region="us-east-1", profile=None), + function_identifier="test-function", + template="template.yaml", + event=None, + no_event=True, + env_vars=None, + dotenv=dotenv_path, # Lambda runtime env vars + debug_port=[5858], + debug_args=None, + debugger_path=None, + container_env_vars=None, + container_dotenv=container_dotenv_path, # Container/debugging env vars + docker_volume_basedir=None, + docker_network=None, + log_file=None, + skip_pull_image=True, + parameter_overrides={}, + layer_cache_basedir=None, + force_image_build=False, + shutdown=False, + container_host="localhost", + container_host_interface="127.0.0.1", + add_host=None, + invoke_image=None, + hook_name=None, + runtime=None, + mount_symlinks=False, + no_mem_limit=False, + ) + + # Verify InvokeContext was called with both dotenv file paths + InvokeContextMock.assert_called_once() + call_kwargs = InvokeContextMock.call_args[1] + self.assertEqual(call_kwargs["dotenv_file"], dotenv_path) + self.assertEqual(call_kwargs["container_dotenv_file"], container_dotenv_path) + finally: + os.unlink(dotenv_path) + os.unlink(container_dotenv_path) + + class TestGetEvent(TestCase): @parameterized.expand([param(STDIN_FILE_NAME), param("somefile")]) @patch("samcli.commands.local.invoke.cli.click") diff --git a/tests/unit/commands/local/start_api/test_cli.py b/tests/unit/commands/local/start_api/test_cli.py index 58b8d28a0f..94993b51b1 100644 --- a/tests/unit/commands/local/start_api/test_cli.py +++ b/tests/unit/commands/local/start_api/test_cli.py @@ -21,10 +21,12 @@ class TestCli(TestCase): def setUp(self): self.template = "template" self.env_vars = "env-vars" + self.dotenv = None self.debug_ports = [123] self.debug_args = "args" self.debugger_path = "/test/path" self.container_env_vars = "container-env-vars" + self.container_dotenv = None self.docker_volume_basedir = "basedir" self.docker_network = "network" self.log_file = "logfile" @@ -78,6 +80,7 @@ def test_cli_must_setup_context_and_start_service(self, local_api_service_mock, template_file=self.template, function_identifier=None, env_vars_file=self.env_vars, + dotenv_file=self.dotenv, docker_volume_basedir=self.docker_volume_basedir, docker_network=self.docker_network, log_file=self.log_file, @@ -86,6 +89,7 @@ def test_cli_must_setup_context_and_start_service(self, local_api_service_mock, debug_args=self.debug_args, debugger_path=self.debugger_path, container_env_vars_file=self.container_env_vars, + container_dotenv_file=self.container_dotenv, parameter_overrides=self.parameter_overrides, layer_cache_basedir=self.layer_cache_basedir, force_image_build=self.force_image_build, @@ -208,10 +212,12 @@ def call_cli(self): static_dir=self.static_dir, template=self.template, env_vars=self.env_vars, + dotenv=self.dotenv, debug_port=self.debug_ports, debug_args=self.debug_args, debugger_path=self.debugger_path, container_env_vars=self.container_env_vars, + container_dotenv=self.container_dotenv, docker_volume_basedir=self.docker_volume_basedir, docker_network=self.docker_network, log_file=self.log_file, diff --git a/tests/unit/commands/local/start_lambda/test_cli.py b/tests/unit/commands/local/start_lambda/test_cli.py index 75531ba255..a9daf66986 100644 --- a/tests/unit/commands/local/start_lambda/test_cli.py +++ b/tests/unit/commands/local/start_lambda/test_cli.py @@ -16,10 +16,12 @@ class TestCli(TestCase): def setUp(self): self.template = "template" self.env_vars = "env-vars" + self.dotenv = None self.debug_ports = [123] self.debug_args = "args" self.debugger_path = "/test/path" self.container_env_vars = "container-env-vars" + self.container_dotenv = None self.docker_volume_basedir = "basedir" self.docker_network = "network" self.log_file = "logfile" @@ -65,7 +67,9 @@ def test_cli_must_setup_context_and_start_service(self, local_lambda_service_moc template_file=self.template, function_identifier=None, env_vars_file=self.env_vars, + dotenv_file=self.dotenv, container_env_vars_file=self.container_env_vars, + container_dotenv_file=self.container_dotenv, docker_volume_basedir=self.docker_volume_basedir, docker_network=self.docker_network, log_file=self.log_file, @@ -168,10 +172,12 @@ def call_cli(self): port=self.port, template=self.template, env_vars=self.env_vars, + dotenv=self.dotenv, debug_port=self.debug_ports, debug_args=self.debug_args, debugger_path=self.debugger_path, container_env_vars=self.container_env_vars, + container_dotenv=self.container_dotenv, docker_volume_basedir=self.docker_volume_basedir, docker_network=self.docker_network, log_file=self.log_file, diff --git a/tests/unit/commands/samconfig/test_samconfig.py b/tests/unit/commands/samconfig/test_samconfig.py index 666a54d0e3..07b868e893 100644 --- a/tests/unit/commands/samconfig/test_samconfig.py +++ b/tests/unit/commands/samconfig/test_samconfig.py @@ -565,10 +565,12 @@ def test_local_invoke(self, do_cli_mock): "event", False, "envvar.json", + None, (1, 2, 3), "args", "mypath", "container-envvar.json", + None, "basedir", "mynetwork", "logfile", @@ -633,10 +635,12 @@ def test_local_invoke_with_runtime_params(self, do_cli_mock): "event", False, "envvar.json", + None, (1, 2, 3), "args", "mypath", "container-envvar.json", + None, "basedir", "mynetwork", "logfile", @@ -702,10 +706,12 @@ def test_local_start_api(self, do_cli_mock): "static_dir", str(Path(os.getcwd(), "mytemplate.yaml")), "envvar.json", + None, (1, 2, 3), "args", "mypath", "container-envvar.json", + None, "basedir", "mynetwork", "logfile", @@ -769,10 +775,12 @@ def test_local_start_lambda(self, do_cli_mock): 12345, str(Path(os.getcwd(), "mytemplate.yaml")), "envvar.json", + None, (1, 2, 3), "args", "mypath", "container-envvar.json", + None, "basedir", "mynetwork", "logfile", @@ -1661,10 +1669,12 @@ def test_override_with_cli_params(self, do_cli_mock): 9999, str(Path(os.getcwd(), "othertemplate.yaml")), "otherenvvar.json", + None, (9, 8, 7), "otherargs", "otherpath", "other-containerenvvar.json", + None, "otherbasedir", "othernetwork", "otherlogfile", @@ -1760,10 +1770,12 @@ def test_override_with_cli_params_and_envvars(self, do_cli_mock): 9999, str(Path(os.getcwd(), "envtemplate.yaml")), "otherenvvar.json", + None, (13579,), "envargs", "otherpath", "other-containerenvvar.json", + None, "envbasedir", "envnetwork", "otherlogfile", diff --git a/tests/unit/local/lambdafn/test_env_vars.py b/tests/unit/local/lambdafn/test_env_vars.py index 4259028de9..edcad14777 100644 --- a/tests/unit/local/lambdafn/test_env_vars.py +++ b/tests/unit/local/lambdafn/test_env_vars.py @@ -200,6 +200,10 @@ def test_with_shell_env_value(self): "none_var": "", "true_var": "true", "false_var": "false", + # This variable is from shell_env but not defined in template variables. + # The resolve() method now collects variable names from all sources (template, shell, overrides), + # so variables from shell_env are included even if they're not in the template. + "myothervar": "somevalue", } environ = EnvironmentVariables( @@ -242,6 +246,12 @@ def test_with_overrides_value(self): "none_var": "", "true_var": "true", "false_var": "false", + # These variables are from shell_env/overrides but not defined in template variables. + # The resolve() method now collects variable names from all sources (template, shell, overrides), + # so variables from shell_env and override_values are included even if they're not in the template. + # This allows .env files to add new variables, not just override existing template variables. + "myothervar": "somevalue", + "unknown_var": "newvalue", } environ = EnvironmentVariables(