diff --git a/samcli/commands/_utils/experimental.py b/samcli/commands/_utils/experimental.py index 871fdb4db1..34b1f7e4d3 100644 --- a/samcli/commands/_utils/experimental.py +++ b/samcli/commands/_utils/experimental.py @@ -272,7 +272,7 @@ def prompt_experimental( if is_experimental_enabled(config_entry): update_experimental_context() return True - confirmed = click.confirm(Colored().yellow(prompt), default=False) + confirmed = click.confirm(Colored().yellow(prompt), default=False, err=True) if confirmed: set_experimental(config_entry=config_entry, enabled=True) update_experimental_context() diff --git a/samcli/commands/_utils/options.py b/samcli/commands/_utils/options.py index 4801f5bab3..dca8fe26a5 100644 --- a/samcli/commands/_utils/options.py +++ b/samcli/commands/_utils/options.py @@ -447,6 +447,25 @@ def common_observability_options(f): return f +def structured_output_click_option(): + """Shared --output option for commands that support structured (JSON) output. + + Uses text|json choices matching the OutputOption enum in samcli.lib.observability.util. + Intended as the single shared contract for all commands adopting --output json. + """ + return click.option( + "--output", + default="text", + help="Output the results from the command in a given output format. " + "Supported formats: text (default), json.", + type=click.Choice(["text", "json"], case_sensitive=False), + ) + + +def structured_output_option(f): + return structured_output_click_option()(f) + + def metadata_click_option(): return click.option( "--metadata", diff --git a/samcli/commands/build/build_context.py b/samcli/commands/build/build_context.py index fd8285accb..86e1f56212 100644 --- a/samcli/commands/build/build_context.py +++ b/samcli/commands/build/build_context.py @@ -4,6 +4,7 @@ import copy import itertools +import json import logging import os import pathlib @@ -14,7 +15,7 @@ import click from samcli.commands._utils.constants import DEFAULT_BUILD_DIR -from samcli.commands._utils.experimental import ExperimentalFlag, prompt_experimental +from samcli.commands._utils.experimental import ExperimentalFlag, is_experimental_enabled, prompt_experimental from samcli.commands._utils.template import ( FOREACH_REQUIRED_ELEMENTS, get_template_data, @@ -36,7 +37,7 @@ BuildInsideContainerError, InvalidBuildGraphException, ) -from samcli.lib.build.workflow_config import UnsupportedRuntimeException +from samcli.lib.build.workflow_config import UnsupportedBuilderException, UnsupportedRuntimeException from samcli.lib.cfn_language_extensions.models import PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES from samcli.lib.cfn_language_extensions.sam_integration import ( contains_loop_variable, @@ -47,13 +48,14 @@ ) from samcli.lib.cfn_language_extensions.utils import is_foreach_key from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable +from samcli.lib.observability.util import OutputOption from samcli.lib.package.language_extensions_packaging import ( _get_prop_value, _leaf_prop_name, _resolve_property_paths, _set_prop_value, ) -from samcli.lib.providers.provider import LayerVersion, ResourcesToBuildCollector, Stack, get_full_path +from samcli.lib.providers.provider import Function, LayerVersion, ResourcesToBuildCollector, Stack, get_full_path from samcli.lib.providers.sam_api_provider import SamApiProvider from samcli.lib.providers.sam_function_provider import SamFunctionProvider from samcli.lib.providers.sam_layer_provider import SamLayerProvider @@ -69,6 +71,27 @@ LOG = logging.getLogger(__name__) +def build_failure_json(ex: Exception) -> str: + """Serialize a build failure into the structured JSON error document. + + Single source of truth for the failure wire format, shared by do_cli and any other + caller so the schema lives in exactly one place. This covers execution failures once + the command body runs; errors raised during click option processing (bad flags, hook + prepare failures) surface as click's standard usage/stderr output, not JSON. + """ + error_type = getattr(ex, "wrapped_from", None) or type(ex).__name__ + return json.dumps( + { + "status": "failure", + "error": { + "type": error_type, + "message": str(ex), + "resource": getattr(ex, "resource_name", None), + }, + } + ) + + class BuildContext: def __init__( self, @@ -104,6 +127,7 @@ def __init__( mount_symlinks: Optional[bool] = False, use_buildkit: Optional[bool] = False, language_extensions: Optional[bool] = None, + output: str = "text", ) -> None: """ Initialize the class @@ -213,6 +237,7 @@ def __init__( self._mount_symlinks = mount_symlinks self._use_buildkit = use_buildkit self._language_extensions_enabled = resolve_language_extensions_enabled(language_extensions) + self._output = OutputOption(output) def __enter__(self) -> "BuildContext": self.set_up() @@ -277,21 +302,25 @@ def run(self) -> None: caught_exception: Optional[Exception] = None try: + resources_to_build = self.get_resources_to_build() + # boolean value indicates if mount with write or not, defaults to READ ONLY mount_with_write = False if self._use_container: if self._mount_with == MountMode.WRITE: mount_with_write = True - else: + elif self._output is not OutputOption.json: # if self._mount_with is NOT WRITE - # check the need of mounting with write permissions and prompt user to enable it if needed + # check the need of mounting with write permissions and prompt user to enable it if needed. + # Skipped in JSON mode: a non-interactive consumer cannot answer the confirm, so fall back + # to the documented READ-only default rather than blocking on stdin (which aborts the build). mount_with_write = prompt_user_to_enable_mount_with_write_if_needed( - self.get_resources_to_build(), + resources_to_build, self.base_dir, ) builder = ApplicationBuilder( - self.get_resources_to_build(), + resources_to_build, self.build_dir, self.base_dir, self.cache_dir, @@ -314,15 +343,13 @@ def run(self) -> None: self._check_exclude_warning() self._check_build_method_experimental_flag() - for f in self.get_resources_to_build().functions: + for f in resources_to_build.functions: EventTracker.track_event(EventName.BUILD_FUNCTION_RUNTIME.value, f.runtime) self._build_result = builder.build() self._handle_build_post_processing(builder, self._build_result) - click.secho("\nBuild Succeeded", fg="green") - # try to use relpath so the command is easier to understand, however, # under Windows, when SAM and (build_dir or output_template_path) are # on different drive, relpath() fails. @@ -336,37 +363,53 @@ def run(self) -> None: build_dir_in_success_message = self.build_dir output_template_path_in_success_message = out_template_path - if self._print_success_message: - msg = self._gen_success_msg( - build_dir_in_success_message, - output_template_path_in_success_message, - os.path.abspath(self.build_dir) == os.path.abspath(DEFAULT_BUILD_DIR), - ) - - click.secho(msg, fg="yellow") + self._print_build_success( + build_dir_in_success_message, + output_template_path_in_success_message, + resources_to_build, + ) except FunctionNotFound as function_not_found_ex: caught_exception = function_not_found_ex - raise UserException( - str(function_not_found_ex), wrapped_from=function_not_found_ex.__class__.__name__ - ) from function_not_found_ex + user_ex = UserException(str(function_not_found_ex), wrapped_from=function_not_found_ex.__class__.__name__) + # The failure is attributable to the specific resource the user asked to build. Resolve + # it to a full path so it matches the resource_id namespace of the success document. + user_ex.resource_name = getattr( + function_not_found_ex, "resource_name", None + ) or self._resolve_resource_full_path(self._resource_identifier) + raise user_ex from function_not_found_ex except ( UnsupportedRuntimeException, + UnsupportedBuilderException, BuildError, BuildInsideContainerError, UnsupportedBuilderLibraryVersionError, InvalidBuildGraphException, + MissingBuildMethodException, ResourceNotFound, ) as ex: caught_exception = ex - click.secho("\nBuild Failed", fg="red") - # Some Exceptions have a deeper wrapped exception that needs to be surfaced # from deeper than just one level down. deep_wrap = getattr(ex, "wrapped_from", None) wrapped_from = deep_wrap if deep_wrap else ex.__class__.__name__ - raise UserException(str(ex), wrapped_from=wrapped_from) from ex + + self._print_build_failure() + + user_ex = UserException(str(ex), wrapped_from=wrapped_from) + # Prefer the resource the exception attributes the failure to. Otherwise fall back to + # the resource the user asked to build - but only for failures actually tied to a + # resource. Resource-agnostic errors (corrupt build graph, outdated builder container) + # keep resource=None rather than blaming whatever resource the user happened to name. + resource_name = getattr(ex, "resource_name", None) + if resource_name is None and not isinstance( + ex, (InvalidBuildGraphException, UnsupportedBuilderLibraryVersionError) + ): + # Resolve to a full path so it matches the resource_id namespace of the success document. + resource_name = self._resolve_resource_full_path(self._resource_identifier) + user_ex.resource_name = resource_name + raise user_ex from ex finally: if self.build_in_source: exception_name = type(caught_exception).__name__ if caught_exception else None @@ -1076,6 +1119,96 @@ def _copy_artifact_paths(self, original_resource: Dict, modified_resource: Dict) if value is not None: _set_prop_value(original_props, prop_name, value) + def _resolve_resource_full_path(self, resource_identifier: Optional[str]) -> Optional[str]: + """ + Resolve a resource identifier (a CLI argument, which may be a bare logical ID) to its + full path, so error.resource stays in the same namespace as resources[].resource_id in + the success document (both nested-stack-qualified, e.g. ChildStack/MyFn). Falls back to + the raw identifier if the resource cannot be looked up. + """ + if not resource_identifier: + return resource_identifier + function = self.function_provider.get(resource_identifier) if self.function_provider else None + if function: + return function.full_path + layer = self.layer_provider.get(resource_identifier) if self.layer_provider else None + if layer: + return layer.full_path + return resource_identifier + + @staticmethod + def _function_to_json(function: Function) -> Dict[str, Any]: + """ + Serialize a built function for the JSON success document. Includes a package_type + discriminator so Image functions (which have runtime: None) are distinguishable from + Zip functions rather than both appearing as runtime: null. + """ + return { + "resource_id": function.full_path, + "type": "function", + "package_type": function.packagetype, + "runtime": function.runtime, + "architecture": function.architecture, + } + + def _print_build_success( + self, artifacts_dir: str, output_template_path: str, resources_to_build: ResourcesToBuildCollector + ) -> None: + """ + Reports a successful build, either as structured JSON or as a human readable message. + + Parameters + ---------- + artifacts_dir: str + A string path representing the folder of built artifacts + output_template_path: str + A string path representing the final template file + resources_to_build: ResourcesToBuildCollector + The functions and layers that were built + """ + if self._output is OutputOption.json: + resources: List[Dict[str, Any]] = [ + self._function_to_json(function) for function in resources_to_build.functions + ] + [ + { + "resource_id": layer.full_path, + "type": "layer", + "compatible_runtimes": layer.compatible_runtimes, + } + for layer in resources_to_build.layers + ] + click.echo( + json.dumps( + { + "status": "success", + "build_dir": artifacts_dir, + "template_file": output_template_path, + "resources": resources, + } + ) + ) + return + + click.secho("\nBuild Succeeded", fg="green") + if self._print_success_message: + msg = self._gen_success_msg( + artifacts_dir, + output_template_path, + os.path.abspath(self.build_dir) == os.path.abspath(DEFAULT_BUILD_DIR), + ) + click.secho(msg, fg="yellow") + + def _print_build_failure(self) -> None: + """ + Prints the human-readable "Build Failed" banner in text mode. + + JSON-mode failure serialization is handled centrally in do_cli, which catches + execution failures raised from run()/set_up() (including exceptions raised before + run()'s try block, e.g. template parse errors or missing layer BuildMethod). + """ + if self._output is not OutputOption.json: + click.secho("\nBuild Failed", fg="red") + def _gen_success_msg(self, artifacts_dir: str, output_template_path: str, is_default_build_dir: bool) -> str: """ Generates a success message containing some suggested commands to run @@ -1141,11 +1274,14 @@ def _setup_build_dir(build_dir: str, clean: bool) -> str: ) raise InvalidBuildDirException(exception_message) - if build_path.exists() and os.listdir(build_dir) and clean: - # build folder contains something inside. Clear everything. - shutil.rmtree(build_dir) + try: + if build_path.exists() and os.listdir(build_dir) and clean: + # build folder contains something inside. Clear everything. + shutil.rmtree(build_dir) - build_path.mkdir(mode=BUILD_DIR_PERMISSIONS, parents=True, exist_ok=True) + build_path.mkdir(mode=BUILD_DIR_PERMISSIONS, parents=True, exist_ok=True) + except OSError as ex: + raise InvalidBuildDirException(f"Unable to use build dir {build_dir}. Reason: {str(ex)}") from ex # ensure path resolving is done after creation: https://bugs.python.org/issue32434 return str(build_path.resolve()) @@ -1386,13 +1522,19 @@ def _check_build_method_experimental_flag(self) -> None: for function in resources_to_build.functions: if function.metadata and function.metadata.get("BuildMethod", "") in EXPERIMENTAL_BUILD_METHODS: build_method = function.metadata.get("BuildMethod", "") + experimental_flag = EXPERIMENTAL_BUILD_METHODS[build_method] + # A JSON consumer cannot answer the interactive beta confirmation. Skip it unless the + # feature is already enabled (via --beta-features / env), in which case prompt_experimental + # just updates the telemetry context and returns without prompting. + if self._output is OutputOption.json and not is_experimental_enabled(experimental_flag): + continue WARNING_MESSAGE = ( f'Build method "{build_method}" is a beta feature.\n' "Please confirm if you would like to proceed\n" 'You can also enable this beta feature with "sam build --beta-features".' ) - prompt_experimental(EXPERIMENTAL_BUILD_METHODS[build_method], WARNING_MESSAGE) + prompt_experimental(experimental_flag, WARNING_MESSAGE) @property def build_in_source(self) -> Optional[bool]: diff --git a/samcli/commands/build/command.py b/samcli/commands/build/command.py index 8ec13607e7..bfa0b327aa 100644 --- a/samcli/commands/build/command.py +++ b/samcli/commands/build/command.py @@ -28,6 +28,7 @@ mount_symlinks_option, parameter_override_option, skip_prepare_infra_option, + structured_output_option, template_option_without_build, terraform_project_root_path_option, use_buildkit_option, @@ -129,6 +130,7 @@ @template_option_without_build @parameter_override_option @docker_common_options +@structured_output_option @cli_framework_options @aws_creds_options @click.argument("resource_logical_id", required=False) @@ -167,6 +169,7 @@ def cli( mount_symlinks: Optional[bool], use_buildkit: Optional[bool], language_extensions: Optional[bool], + output: str, ) -> None: """ `sam build` command entry point @@ -201,6 +204,7 @@ def cli( mount_symlinks, use_buildkit, language_extensions, + output, ) # pragma: no cover @@ -230,12 +234,14 @@ def do_cli( # pylint: disable=too-many-locals, too-many-statements mount_symlinks: Optional[bool], use_buildkit: Optional[bool], language_extensions: Optional[bool], + output: str = "text", ) -> None: """ Implementation of the ``cli`` method """ - from samcli.commands.build.build_context import BuildContext + from samcli.commands.build.build_context import BuildContext, build_failure_json + from samcli.lib.observability.util import OutputOption LOG.debug("'build' command is called") if cached: @@ -243,37 +249,57 @@ def do_cli( # pylint: disable=too-many-locals, too-many-statements if use_container: LOG.info("Starting Build inside a container") - processed_env_vars = process_env_var(container_env_var) - processed_build_images = process_image_options(build_image) + try: + # Inside the try so option-preprocessing errors (e.g. InvalidImageException from a + # malformed --build-image) are serialized too, not just failures from within BuildContext. + processed_env_vars = process_env_var(container_env_var) + processed_build_images = process_image_options(build_image) - with BuildContext( - function_identifier, - template, - base_dir, - build_dir, - cache_dir, - cached, - parallel=parallel, - clean=clean, - manifest_path=manifest_path, - use_container=use_container, - parameter_overrides=parameter_overrides, - docker_network=docker_network, - skip_pull_image=skip_pull_image, - mode=mode, - container_env_var=processed_env_vars, - container_env_var_file=container_env_var_file, - build_images=processed_build_images, - excluded_resources=exclude, - aws_region=click_ctx.region, - hook_name=hook_name, - build_in_source=build_in_source, - mount_with=mount_with, - mount_symlinks=mount_symlinks, - use_buildkit=use_buildkit, - language_extensions=language_extensions, - ) as ctx: - ctx.run() + with BuildContext( + function_identifier, + template, + base_dir, + build_dir, + cache_dir, + cached, + parallel=parallel, + clean=clean, + manifest_path=manifest_path, + use_container=use_container, + parameter_overrides=parameter_overrides, + docker_network=docker_network, + skip_pull_image=skip_pull_image, + mode=mode, + container_env_var=processed_env_vars, + container_env_var_file=container_env_var_file, + build_images=processed_build_images, + excluded_resources=exclude, + aws_region=click_ctx.region, + hook_name=hook_name, + build_in_source=build_in_source, + mount_with=mount_with, + mount_symlinks=mount_symlinks, + use_buildkit=use_buildkit, + language_extensions=language_extensions, + output=output, + ) as ctx: + ctx.run() + except Exception as ex: + # Central JSON failure serialization for sam build --output json. Catching broadly here + # (rather than per-exception inside run()) emits a JSON failure document for every + # execution error surfaced from BuildContext, including bare-Exception template errors + # raised during __enter__/set_up (e.g. InvalidLayerReference, RemoteStackLocationNotSupported) + # that are not UserException subclasses. We re-raise unconditionally so @track_command still + # records telemetry and wraps non-UserException errors as UnhandledException, and so run()'s + # text "Build Failed" banner path is unaffected (no double-emit: run() no longer emits JSON). + # + # Scope: this covers failures once do_cli runs. Errors raised earlier during click option + # processing (invalid flags, or a --hook-name prepare-hook failure re-raised by + # track_command) surface as click's standard usage/stderr output, not JSON. Consumers should + # treat "exit != 0 with empty stdout" as an invocation error, not an execution failure. + if OutputOption(output) is OutputOption.json: + click.echo(build_failure_json(ex)) + raise def _get_mode_value_from_envvar(name: str, choices: List[str]) -> Optional[str]: diff --git a/samcli/commands/build/core/options.py b/samcli/commands/build/core/options.py index 4b86570817..4c4da7c134 100644 --- a/samcli/commands/build/core/options.py +++ b/samcli/commands/build/core/options.py @@ -38,6 +38,8 @@ "base_dir", ] +OUTPUT_OPTIONS: List[str] = ["output"] + TEMPLATE_OPTIONS: List[str] = ["parameter_overrides", "language_extensions"] TERRAFORM_HOOK_OPTIONS: List[str] = ["terraform_project_root_path"] @@ -49,6 +51,7 @@ + BUILD_STRATEGY_OPTIONS + CONTAINER_OPTION_NAMES + ARTIFACT_LOCATION_OPTIONS + + OUTPUT_OPTIONS + EXTENSION_OPTIONS + CONFIGURATION_OPTION_NAMES + ALL_COMMON_OPTIONS @@ -66,6 +69,7 @@ "Artifact Location Options": { "option_names": {opt: {"rank": idx} for idx, opt in enumerate(ARTIFACT_LOCATION_OPTIONS)} }, + "Output Options": {"option_names": {opt: {"rank": idx} for idx, opt in enumerate(OUTPUT_OPTIONS)}}, "Extension Options": {"option_names": {opt: {"rank": idx} for idx, opt in enumerate(EXTENSION_OPTIONS)}}, "Configuration Options": { "option_names": {opt: {"rank": idx} for idx, opt in enumerate(CONFIGURATION_OPTION_NAMES)}, diff --git a/samcli/commands/build/utils.py b/samcli/commands/build/utils.py index 7f5668efbf..5f496cf1c2 100644 --- a/samcli/commands/build/utils.py +++ b/samcli/commands/build/utils.py @@ -108,7 +108,8 @@ def prompt(config: CONFIG, source_dir: str) -> bool: f"mounting with write permissions to the source code directory {source_dir}. " f"Some files in this directory may be changed or added by the build process. " f"Pass `--mount-with WRITE` to `sam build` CLI to avoid this confirmation. " - f"\nWould you like to enable mounting with write permissions? " + f"\nWould you like to enable mounting with write permissions? ", + err=True, ): return True return False diff --git a/samcli/commands/exceptions.py b/samcli/commands/exceptions.py index 5f835e0d33..30c1604d9c 100644 --- a/samcli/commands/exceptions.py +++ b/samcli/commands/exceptions.py @@ -23,6 +23,11 @@ class UserException(click.ClickException): exit_code = 1 + # Optional logical ID of the resource a failure is attributable to. Used by commands + # that emit structured (JSON) error output. Defaults to None for failures not tied to + # a single resource. + resource_name: Optional[str] = None + def __init__(self, message, wrapped_from=None): self.wrapped_from = wrapped_from diff --git a/samcli/lib/build/build_strategy.py b/samcli/lib/build/build_strategy.py index 87b412b41c..67fd9cd20d 100644 --- a/samcli/lib/build/build_strategy.py +++ b/samcli/lib/build/build_strategy.py @@ -20,8 +20,14 @@ LayerBuildDefinition, ) from samcli.lib.build.dependency_hash_generator import DependencyHashGenerator -from samcli.lib.build.exceptions import MissingBuildMethodException +from samcli.lib.build.exceptions import ( + BuildError, + BuildInsideContainerError, + MissingBuildMethodException, + UnsupportedBuilderLibraryVersionError, +) from samcli.lib.build.utils import warn_on_invalid_architecture +from samcli.lib.build.workflow_config import UnsupportedBuilderException, UnsupportedRuntimeException from samcli.lib.utils import osutils from samcli.lib.utils.architecture import X86_64 from samcli.lib.utils.async_utils import AsyncContext @@ -144,6 +150,27 @@ def build_single_function_definition(self, build_definition: FunctionBuildDefini """ Build the unique definition and then copy the artifact to the corresponding function folder """ + try: + return self._do_build_single_function_definition(build_definition) + except ( + BuildError, + UnsupportedRuntimeException, + UnsupportedBuilderException, + BuildInsideContainerError, + UnsupportedBuilderLibraryVersionError, + ) as ex: + if getattr(ex, "resource_name", None) is None: + # Representative resource id. Functions sharing runtime + CodeUri + metadata + # collapse into one build definition, so a single failure can affect several; + # get_full_path() names the first. Reporting one representative is the deliberate + # contract here — the JSON failure document carries a single error.resource. + ex.resource_name = build_definition.get_full_path() + raise + + def _do_build_single_function_definition(self, build_definition: FunctionBuildDefinition) -> Dict[str, str]: + """ + Internal implementation of build_single_function_definition + """ function_build_results = {} LOG.info( "Building codeuri: %s runtime: %s architecture: %s functions: %s", @@ -210,6 +237,23 @@ def build_single_layer_definition(self, layer_definition: LayerBuildDefinition) """ Build the unique definition and then copy the artifact to the corresponding layer folder """ + try: + return self._do_build_single_layer_definition(layer_definition) + except ( + BuildError, + UnsupportedRuntimeException, + UnsupportedBuilderException, + BuildInsideContainerError, + UnsupportedBuilderLibraryVersionError, + ) as ex: + if getattr(ex, "resource_name", None) is None: + ex.resource_name = layer_definition.full_path + raise + + def _do_build_single_layer_definition(self, layer_definition: LayerBuildDefinition) -> Dict[str, str]: + """ + Internal implementation of build_single_layer_definition + """ layer = layer_definition.layer LOG.info("Building layer '%s'", layer.full_path) if layer.build_method is None: diff --git a/samcli/lib/build/exceptions.py b/samcli/lib/build/exceptions.py index e1f4ff50fc..75621c2cd7 100644 --- a/samcli/lib/build/exceptions.py +++ b/samcli/lib/build/exceptions.py @@ -2,10 +2,14 @@ Build Related Exceptions. """ +from typing import Optional + from samcli.commands.exceptions import UserException class UnsupportedBuilderLibraryVersionError(Exception): + resource_name: Optional[str] = None + def __init__(self, container_name: str, error_msg: str) -> None: msg = ( "You are running an outdated version of Docker container '{container_name}' that is not compatible with" @@ -15,13 +19,15 @@ def __init__(self, container_name: str, error_msg: str) -> None: class BuildError(Exception): + resource_name: Optional[str] = None + def __init__(self, wrapped_from: str, msg: str) -> None: self.wrapped_from = wrapped_from Exception.__init__(self, msg) class BuildInsideContainerError(Exception): - pass + resource_name: Optional[str] = None class DockerConnectionError(BuildError): diff --git a/samcli/lib/build/workflow_config.py b/samcli/lib/build/workflow_config.py index 0a453880a1..a4f97f955a 100644 --- a/samcli/lib/build/workflow_config.py +++ b/samcli/lib/build/workflow_config.py @@ -27,11 +27,11 @@ class UnsupportedRuntimeException(Exception): - pass + resource_name: Optional[str] = None class UnsupportedBuilderException(Exception): - pass + resource_name: Optional[str] = None WorkFlowSelector = Union["BasicWorkflowSelector", "ManifestWorkflowSelector"] diff --git a/schema/samcli.json b/schema/samcli.json index 40498eb619..a4d17a0117 100644 --- a/schema/samcli.json +++ b/schema/samcli.json @@ -300,7 +300,7 @@ "properties": { "parameters": { "title": "Parameters for the build command", - "description": "Available parameters for the build command:\n* terraform_project_root_path:\nUsed for passing the Terraform project root directory path. Current directory will be used as a default value, if this parameter is not provided.\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* use_container:\nBuild functions within an AWS Lambda-like container.\n* use_buildkit:\nEnable buildkit for container image builds. Requires Docker with buildx plugin or Finch CLI.\n* build_in_source:\nOpts in to build project in the source folder. The following workflows support building in source: ['nodejs16.x', 'nodejs18.x', 'nodejs20.x', 'nodejs22.x', 'Makefile', 'esbuild']\n* language_extensions:\nExpand AWS::LanguageExtensions transforms (Fn::ForEach, Fn::Length, Fn::ToJsonString, Fn::FindInMap with DefaultValue) locally before running SAM transforms. Off by default. Equivalent env var: SAM_CLI_ENABLE_LANGUAGE_EXTENSIONS=1.\n* container_env_var:\nEnvironment variables to be passed into build containers\nResource format (FuncName.VarName=Value) or Global format (VarName=Value).\n\n Example: --container-env-var Func1.VAR1=value1 --container-env-var VAR2=value2\n* container_env_var_file:\nEnvironment variables json file (e.g., env_vars.json) to be passed to containers.\n* build_image:\nContainer image URIs for building functions/layers. You can specify for all functions/layers with just the image URI (--build-image public.ecr.aws/sam/build-nodejs18.x:latest). You can specify for each individual function with (--build-image FunctionLogicalID=public.ecr.aws/sam/build-nodejs18.x:latest). A combination of the two can be used. If a function does not have build image specified or an image URI for all functions, the default SAM CLI build images will be used.\n* exclude:\nName of the resource(s) to exclude from AWS SAM CLI build.\n* parallel:\nEnable parallel builds for AWS SAM template's functions and layers.\n* mount_with:\nSpecify mount mode for building functions/layers inside container. If it is mounted with write permissions, some files in source code directory may be changed/added by the build process. By default the source code directory is read only.\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* build_dir:\nDirectory to store build artifacts.Note: This directory will be first removed before starting a build.\n* cache_dir:\nDirectory to store cached artifacts. The default cache directory is .aws-sam/cache\n* base_dir:\nResolve relative paths to function's source code with respect to this directory. Use this if SAM template and source code are not in same enclosing folder. By default, relative paths are resolved with respect to the SAM template's location.\n* manifest:\nPath to a custom dependency manifest. Example: custom-package.json\n* cached:\nEnable cached builds.Reuse build artifacts that have not changed from previous builds. \n\nAWS SAM CLI evaluates if files in your project directory have changed. \n\nNote: AWS SAM CLI does not evaluate changes made to third party modules that the project depends on.Example: Python function includes a requirements.txt file with the following entry requests=1.x and the latest request module version changes from 1.1 to 1.2, AWS SAM CLI will not pull the latest version until a non-cached build is run.\n* template_file:\nAWS SAM template file.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\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* 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 build command:\n* terraform_project_root_path:\nUsed for passing the Terraform project root directory path. Current directory will be used as a default value, if this parameter is not provided.\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* use_container:\nBuild functions within an AWS Lambda-like container.\n* use_buildkit:\nEnable buildkit for container image builds. Requires Docker with buildx plugin or Finch CLI.\n* build_in_source:\nOpts in to build project in the source folder. The following workflows support building in source: ['nodejs16.x', 'nodejs18.x', 'nodejs20.x', 'nodejs22.x', 'Makefile', 'esbuild']\n* language_extensions:\nExpand AWS::LanguageExtensions transforms (Fn::ForEach, Fn::Length, Fn::ToJsonString, Fn::FindInMap with DefaultValue) locally before running SAM transforms. Off by default. Equivalent env var: SAM_CLI_ENABLE_LANGUAGE_EXTENSIONS=1.\n* container_env_var:\nEnvironment variables to be passed into build containers\nResource format (FuncName.VarName=Value) or Global format (VarName=Value).\n\n Example: --container-env-var Func1.VAR1=value1 --container-env-var VAR2=value2\n* container_env_var_file:\nEnvironment variables json file (e.g., env_vars.json) to be passed to containers.\n* build_image:\nContainer image URIs for building functions/layers. You can specify for all functions/layers with just the image URI (--build-image public.ecr.aws/sam/build-nodejs18.x:latest). You can specify for each individual function with (--build-image FunctionLogicalID=public.ecr.aws/sam/build-nodejs18.x:latest). A combination of the two can be used. If a function does not have build image specified or an image URI for all functions, the default SAM CLI build images will be used.\n* exclude:\nName of the resource(s) to exclude from AWS SAM CLI build.\n* parallel:\nEnable parallel builds for AWS SAM template's functions and layers.\n* mount_with:\nSpecify mount mode for building functions/layers inside container. If it is mounted with write permissions, some files in source code directory may be changed/added by the build process. By default the source code directory is read only.\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* build_dir:\nDirectory to store build artifacts.Note: This directory will be first removed before starting a build.\n* cache_dir:\nDirectory to store cached artifacts. The default cache directory is .aws-sam/cache\n* base_dir:\nResolve relative paths to function's source code with respect to this directory. Use this if SAM template and source code are not in same enclosing folder. By default, relative paths are resolved with respect to the SAM template's location.\n* manifest:\nPath to a custom dependency manifest. Example: custom-package.json\n* cached:\nEnable cached builds.Reuse build artifacts that have not changed from previous builds. \n\nAWS SAM CLI evaluates if files in your project directory have changed. \n\nNote: AWS SAM CLI does not evaluate changes made to third party modules that the project depends on.Example: Python function includes a requirements.txt file with the following entry requests=1.x and the latest request module version changes from 1.1 to 1.2, AWS SAM CLI will not pull the latest version until a non-cached build is run.\n* template_file:\nAWS SAM template file.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\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* output:\nOutput the results from the command in a given output format. Supported formats: text (default), json.\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_project_root_path": { @@ -433,6 +433,16 @@ "type": "string", "description": "Name 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." }, + "output": { + "title": "output", + "type": "string", + "description": "Output the results from the command in a given output format. Supported formats: text (default), json.", + "default": "text", + "enum": [ + "json", + "text" + ] + }, "beta_features": { "title": "beta_features", "type": "boolean", diff --git a/tests/unit/commands/_utils/test_experimental.py b/tests/unit/commands/_utils/test_experimental.py index a73760a280..2e202dd95b 100644 --- a/tests/unit/commands/_utils/test_experimental.py +++ b/tests/unit/commands/_utils/test_experimental.py @@ -120,5 +120,5 @@ def test_prompt_experimental(self, update_experimental_context, enabled_mock, co prompt_experimental(config_entry, prompt) set_experimental_mock.assert_called_once_with(config_entry=config_entry, enabled=True) enabled_mock.assert_called_once_with(config_entry) - confirm_mock.assert_called_once_with(Colored().yellow(prompt), default=False) + confirm_mock.assert_called_once_with(Colored().yellow(prompt), default=False, err=True) update_experimental_context.assert_called_once() diff --git a/tests/unit/commands/buildcmd/test_build_context.py b/tests/unit/commands/buildcmd/test_build_context.py index eab988f231..e29e4b1d9f 100644 --- a/tests/unit/commands/buildcmd/test_build_context.py +++ b/tests/unit/commands/buildcmd/test_build_context.py @@ -1,4 +1,7 @@ +import io +import json import os +from contextlib import redirect_stdout from unittest import TestCase from unittest.mock import ANY, MagicMock, Mock, call, patch @@ -16,8 +19,9 @@ ) from samcli.lib.build.build_graph import DEFAULT_DEPENDENCIES_DIR from samcli.lib.build.bundler import EsbuildBundlerManager -from samcli.lib.build.workflow_config import UnsupportedRuntimeException -from samcli.lib.providers.provider import Function, get_function_build_info +from samcli.lib.build.workflow_config import UnsupportedBuilderException, UnsupportedRuntimeException +from samcli.lib.observability.util import OutputOption +from samcli.lib.providers.provider import Function, ResourcesToBuildCollector, get_function_build_info from samcli.lib.telemetry.event import EventName, UsedFeature from samcli.lib.utils.osutils import BUILD_DIR_PERMISSIONS from samcli.lib.utils.packagetype import IMAGE, ZIP @@ -767,6 +771,22 @@ def test_build_dir_exists_with_non_empty_dir(self, pathlib_patch, os_patch, shut shutil_patch.rmtree.assert_called_once_with(build_dir) pathlib_patch.Path.cwd.assert_called_once() + @patch("samcli.commands.build.build_context.shutil") + @patch("samcli.commands.build.build_context.os") + @patch("samcli.commands.build.build_context.pathlib") + def test_rmtree_oserror_is_converted_to_invalid_build_dir(self, pathlib_patch, os_patch, shutil_patch): + # A failure clearing the build dir (e.g. permission denied, file held open on Windows) + # must surface as InvalidBuildDirException so --output json still emits a JSON error. + path_mock = Mock() + pathlib_patch.Path.return_value = path_mock + os_patch.path.abspath.side_effect = ["/somepath", "/cwd/path"] + path_mock.exists.return_value = True + os_patch.listdir.return_value = True + shutil_patch.rmtree.side_effect = OSError("permission denied") + + with self.assertRaises(InvalidBuildDirException): + BuildContext._setup_build_dir("/somepath", True) + @patch("samcli.commands.build.build_context.shutil") @patch("samcli.commands.build.build_context.os") @patch("samcli.commands.build.build_context.pathlib") @@ -1088,14 +1108,63 @@ def test_run_build_context( # assert that nested stack manager is called by both root stack and child stack given_nested_stack_manager.generate_auto_dependency_layer_stack.assert_has_calls([call(), call()]) + @patch("samcli.commands.build.build_context.SamLocalStackProvider.find_root_stack") + @patch("samcli.commands.build.build_context.BuildContext.set_up") + @patch("samcli.commands.build.build_context.BuildContext._handle_build_post_processing") + @patch("samcli.commands.build.build_context.BuildContext._handle_build_pre_processing") + @patch("samcli.commands.build.build_context.BuildContext.get_resources_to_build") + @patch("samcli.commands.build.build_context.BuildContext._is_sam_template", return_value=False) + @patch("samcli.commands.build.build_context.ApplicationBuilder") + def test_run_json_mode_emits_single_parseable_document( + self, + ApplicationBuilderMock, + is_sam_template_mock, + resources_mock, + pre_processing_mock, + post_processing_mock, + set_up_mock, + find_root_stack_mock, + ): + """run() in JSON mode must write exactly one parseable JSON document to stdout.""" + resources_mock.return_value = Mock(functions=[get_function("Fn", runtime="python3.12")], layers=[]) + ApplicationBuilderMock.return_value.build.return_value = ApplicationBuildResult(Mock(), "artifacts") + find_root_stack_mock.return_value.get_output_template_path.return_value = "build_dir/template.yaml" + + build_context = BuildContext( + resource_identifier=None, + template_file="template_file", + base_dir="base_dir", + build_dir="build_dir", + cache_dir="cache_dir", + cached=False, + parallel=False, + mode=None, + output="json", + ) + + captured = io.StringIO() + with redirect_stdout(captured): + build_context.run() + + # Must be exactly one JSON document on stdout -- json.loads fails on any extra output + result = json.loads(captured.getvalue()) + self.assertEqual(result["status"], "success") + self.assertIn("build_dir", result) + self.assertIn("resources", result) + @parameterized.expand( [ - (UnsupportedRuntimeException(), "UnsupportedRuntimeException"), - (BuildInsideContainerError(), "BuildInsideContainerError"), - (BuildError(wrapped_from=DeepWrap().__class__.__name__, msg="Test"), "DeepWrap"), + # (exception, expected wrapped_from, expected resource_name) + # Resource-tied failures resolve the requested identifier to its full path (the mocked + # function provider returns func1); resource-agnostic ones keep resource=None. + (UnsupportedRuntimeException(), "UnsupportedRuntimeException", "func1"), + (UnsupportedBuilderException(), "UnsupportedBuilderException", "func1"), + (BuildInsideContainerError(), "BuildInsideContainerError", "func1"), + (BuildError(wrapped_from=DeepWrap().__class__.__name__, msg="Test"), "DeepWrap", "func1"), ( UnsupportedBuilderLibraryVersionError(container_name="name", error_msg="msg"), "UnsupportedBuilderLibraryVersionError", + None, ), ] ) @@ -1116,6 +1185,7 @@ def test_must_catch_known_exceptions( self, exception, wrapped_exception, + expected_resource_name, esbuild_bundler_manager_mock, os_mock, get_template_data_mock, @@ -1179,6 +1249,9 @@ def test_must_catch_known_exceptions( self.assertEqual(str(ctx.exception), str(exception)) self.assertEqual(wrapped_exception, ctx.exception.wrapped_from) + # Resource-tied failures fall back to the requested resource id; resource-agnostic ones + # (e.g. outdated builder container) keep resource=None so no innocent resource is blamed. + self.assertEqual(ctx.exception.resource_name, expected_resource_name) @patch("samcli.commands.build.build_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.build.build_context.SamApiProvider") @@ -1301,6 +1374,39 @@ def test_build_in_source_event_sent( EventName.USED_FEATURE.value, UsedFeature.BUILD_IN_SOURCE.value, "FunctionNotFound" ) + @patch("samcli.commands.build.build_context.prompt_user_to_enable_mount_with_write_if_needed") + @patch("samcli.commands.build.build_context.BuildContext._is_sam_template", return_value=False) + @patch("samcli.commands.build.build_context.BuildContext._handle_build_pre_processing") + @patch("samcli.commands.build.build_context.BuildContext.get_resources_to_build") + @patch("samcli.commands.build.build_context.BuildContext._check_exclude_warning") + @patch("samcli.commands.build.build_context.BuildContext._check_build_method_experimental_flag") + @patch("samcli.lib.build.app_builder.ApplicationBuilder.build") + def test_skips_mount_prompt_in_json_mode( + self, mock_build, mock_experimental, mock_warning, mock_get_resources, mock_pre_processing, _, mock_prompt + ): + # --use-container without --mount-with WRITE normally prompts on stdin. In JSON mode a + # non-interactive consumer cannot answer, so the prompt is skipped (defaulting to READ-only) + # rather than aborting the build. build() raises to short-circuit right after the decision. + mock_build.side_effect = FunctionNotFound() + context = BuildContext( + resource_identifier="", + template_file="template_file", + base_dir="base_dir", + build_dir="build_dir", + cache_dir="cache_dir", + cached=False, + parallel=False, + mode="mode", + use_container=True, + mount_with=MountMode.READ.value, + output="json", + ) + + with self.assertRaises(UserException): + context.run() + + mock_prompt.assert_not_called() + class TestBuildContext_is_sam_template(TestCase): @parameterized.expand( @@ -1411,6 +1517,172 @@ def test_gen_message_with_non_default_build_with_hook_package(self): self.assertEqual(msg, expected_msg) +class TestBuildContext_print_build_success(TestCase): + def setUp(self): + self.build_dir = ".aws-sam/build" + self.template_file = "template_file" + + self.build_context = BuildContext( + resource_identifier="function_identifier", + template_file=self.template_file, + base_dir="base_dir", + build_dir=self.build_dir, + cache_dir="cache_dir", + parallel=False, + mode="mode", + cached=False, + output="json", + ) + self.build_context._hook_name = False + + @staticmethod + def _collector(functions=None, layers=None): + collector = ResourcesToBuildCollector() + collector.add_functions(functions or []) + collector.add_layers(layers or []) + return collector + + @patch("samcli.commands.build.build_context.click.secho") + @patch("samcli.commands.build.build_context.click.echo") + def test_json_with_functions_and_layers(self, echo_mock, secho_mock): + collector = self._collector( + functions=[get_function("Fn", runtime="python3.12")], + layers=[DummyLayer("Lyr", "python3.12")], + ) + collector.layers[0].full_path = "Lyr" + collector.layers[0].compatible_runtimes = ["python3.12", "python3.11"] + + self.build_context._print_build_success("artifacts", "out_template", collector) + + # JSON goes to click.echo; text-mode secho must not be used + self.assertEqual(echo_mock.call_count, 1) + secho_mock.assert_not_called() + + result = json.loads(echo_mock.call_args[0][0]) + self.assertEqual(result["status"], "success") + self.assertEqual(result["build_dir"], "artifacts") + self.assertEqual(result["template_file"], "out_template") + # Order is functions first, then layers + self.assertEqual(result["resources"][0]["type"], "function") + self.assertEqual(result["resources"][0]["resource_id"], "Fn") + self.assertEqual(result["resources"][1]["type"], "layer") + self.assertEqual(result["resources"][1]["resource_id"], "Lyr") + self.assertEqual(result["resources"][1]["compatible_runtimes"], ["python3.12", "python3.11"]) + + @patch("samcli.commands.build.build_context.click.echo") + def test_json_empty_collector(self, echo_mock): + self.build_context._print_build_success("artifacts", "out_template", self._collector()) + + result = json.loads(echo_mock.call_args[0][0]) + self.assertEqual(result["resources"], []) + + @patch("samcli.commands.build.build_context.click.echo") + def test_json_image_function_has_package_type_discriminator(self, echo_mock): + # Image functions have runtime: None; package_type lets a consumer distinguish them + # from a malformed Zip entry. + image_fn = get_function("ImgFn", runtime=None, packagetype=IMAGE) + collector = self._collector(functions=[image_fn]) + + self.build_context._print_build_success("artifacts", "out_template", collector) + + resource = json.loads(echo_mock.call_args[0][0])["resources"][0] + self.assertEqual(resource["package_type"], "Image") + self.assertIsNone(resource["runtime"]) + + @patch("samcli.commands.build.build_context.click.echo") + def test_json_architecture_defaults_to_x86_64_when_absent(self, echo_mock): + # get_function() builds a Function with architectures=None + # function.architecture resolves to X86_64 when absent + collector = self._collector(functions=[get_function("Fn", runtime="python3.12")]) + + self.build_context._print_build_success("artifacts", "out_template", collector) + + result = json.loads(echo_mock.call_args[0][0]) + self.assertEqual(result["resources"][0]["architecture"], "x86_64") + + @patch("samcli.commands.build.build_context.click.echo") + def test_json_architecture_reports_specified_value(self, echo_mock): + # Function with explicit architecture + function = get_function("Fn", runtime="python3.12")._replace(architectures=["arm64"]) + collector = self._collector(functions=[function]) + + self.build_context._print_build_success("artifacts", "out_template", collector) + + result = json.loads(echo_mock.call_args[0][0]) + self.assertEqual(result["resources"][0]["architecture"], "arm64") + + @patch("samcli.commands.build.build_context.click.secho") + @patch("samcli.commands.build.build_context.click.echo") + def test_text_mode_prints_banner_and_message(self, echo_mock, secho_mock): + self.build_context._output = OutputOption.text + collector = self._collector(functions=[get_function("Fn", runtime="python3.12")]) + + self.build_context._print_build_success("artifacts", "out_template", collector) + + # Text mode uses secho, never echo + echo_mock.assert_not_called() + secho_mock.assert_any_call("\nBuild Succeeded", fg="green") + self.assertEqual(secho_mock.call_count, 2) + + @patch("samcli.commands.build.build_context.click.secho") + @patch("samcli.commands.build.build_context.click.echo") + def test_text_mode_banner_only_when_success_message_disabled(self, echo_mock, secho_mock): + self.build_context._output = OutputOption.text + self.build_context._print_success_message = False + + self.build_context._print_build_success("artifacts", "out_template", self._collector()) + + echo_mock.assert_not_called() + secho_mock.assert_called_once_with("\nBuild Succeeded", fg="green") + + +class TestBuildContext_print_build_failure(TestCase): + def setUp(self): + self.build_context = BuildContext( + resource_identifier="function_identifier", + template_file="template_file", + base_dir="base_dir", + build_dir="build_dir", + cache_dir="cache_dir", + parallel=False, + mode="mode", + cached=False, + output="json", + ) + + @patch("samcli.commands.build.build_context.click.secho") + @patch("samcli.commands.build.build_context.click.echo") + def test_text_mode_prints_banner(self, echo_mock, secho_mock): + self.build_context._output = OutputOption.text + + self.build_context._print_build_failure() + + echo_mock.assert_not_called() + secho_mock.assert_called_once_with("\nBuild Failed", fg="red") + + @patch("samcli.commands.build.build_context.click.secho") + @patch("samcli.commands.build.build_context.click.echo") + def test_json_mode_stays_silent(self, echo_mock, secho_mock): + # In JSON mode _print_build_failure emits nothing (do_cli serializes the failure). + self.build_context._output = OutputOption.json + + self.build_context._print_build_failure() + + echo_mock.assert_not_called() + secho_mock.assert_not_called() + + def test_resolves_bare_id_to_full_path(self): + # A nested-stack function's full_path differs from the bare CLI id; resolving keeps + # error.resource in the same namespace as the success document's resource_id. + nested = get_function("MyFn")._replace(stack_path="ChildStack") + self.build_context._function_provider = Mock() + self.build_context._function_provider.get.return_value = nested + self.build_context._layer_provider = Mock() + self.build_context._layer_provider.get.return_value = None + + self.assertEqual(self.build_context._resolve_resource_full_path("MyFn"), "ChildStack/MyFn") + + class TestBuildContext_check_build_method_experimental_flag(TestCase): def setUp(self): self.build_context = BuildContext( @@ -1463,6 +1735,24 @@ def test_check_build_method_experimental_flag_no_metadata(self, mock_get_resourc mock_prompt.assert_not_called() + @patch("samcli.commands.build.build_context.is_experimental_enabled", return_value=False) + @patch("samcli.commands.build.build_context.prompt_experimental") + @patch("samcli.commands.build.build_context.BuildContext.get_resources_to_build") + def test_skips_beta_prompt_in_json_mode_when_not_enabled(self, mock_get_resources, mock_prompt, _): + # A JSON consumer cannot answer the confirm; skip it (rather than aborting on EOF) and let the + # non-gating beta build proceed. If the flag were already enabled, prompt_experimental would run + # to update telemetry context - covered by is_experimental_enabled=False here. + self.build_context._output = OutputOption.json + mock_function = Mock() + mock_function.metadata = {"BuildMethod": "python-uv"} + mock_resources = Mock() + mock_resources.functions = [mock_function] + mock_get_resources.return_value = mock_resources + + self.build_context._check_build_method_experimental_flag() + + mock_prompt.assert_not_called() + class TestBuildContext_get_template_for_output(TestCase): """Tests for the _get_template_for_output method that handles original template preservation.""" diff --git a/tests/unit/commands/buildcmd/test_command.py b/tests/unit/commands/buildcmd/test_command.py index d32b27bf6f..42e4c70b5f 100644 --- a/tests/unit/commands/buildcmd/test_command.py +++ b/tests/unit/commands/buildcmd/test_command.py @@ -1,3 +1,4 @@ +import json import os import click @@ -5,7 +6,9 @@ from unittest.mock import Mock, patch from samcli.commands.build.command import do_cli, _get_mode_value_from_envvar +from samcli.commands.build.exceptions import MissingBuildMethodException from samcli.commands.build.utils import MountMode +from samcli.commands.exceptions import UserException class TestDoCli(TestCase): @@ -70,11 +73,77 @@ def test_must_succeed_build(self, os_mock, BuildContextMock, mock_build_click): mount_symlinks=True, use_buildkit=False, language_extensions=None, + output="text", ) ctx_mock.run.assert_called_with() self.assertEqual(ctx_mock.run.call_count, 1) +class TestDoCliJsonFailure(TestCase): + """do_cli centrally serializes failures to JSON in --output json mode, covering every + UserException path (including non-BuildError ones the earlier per-exception handling missed).""" + + def _run_expecting(self, raised_exception): + base_args = ["function_identifier", "template", "base_dir", "build_dir", "cache_dir", "clean"] + echoed = [] + with patch("samcli.commands.build.build_context.BuildContext") as BuildContextMock: + BuildContextMock.return_value.__enter__.return_value.run.side_effect = raised_exception + with patch("samcli.commands.build.command.click.echo", side_effect=echoed.append): + with self.assertRaises(type(raised_exception)): + do_cli( + Mock(), + *base_args, + "use_container", + "cached", + "parallel", + "manifest_path", + "docker_network", + "skip_pull_image", + "parameter_overrides", + "mode", + (""), + "container_env_var_file", + (), + (), + hook_name=None, + build_in_source=False, + mount_with=MountMode.READ, + mount_symlinks=True, + use_buildkit=False, + language_extensions=None, + output="json", + ) + return echoed + + def test_json_failure_includes_type_message_and_resource(self): + # run() re-raises build failures as UserException, carrying wrapped_from + resource_name. + ex = UserException("dependency failure", wrapped_from="WorkflowFailedError") + ex.resource_name = "HelloWorldFunction" + + result = json.loads(self._run_expecting(ex)[0]) + + self.assertEqual(result["status"], "failure") + self.assertEqual(result["error"]["type"], "WorkflowFailedError") + self.assertEqual(result["error"]["message"], "dependency failure") + self.assertEqual(result["error"]["resource"], "HelloWorldFunction") + + def test_json_failure_for_non_build_error_user_exception(self): + # MissingBuildMethodException is a UserException raised before/around run()'s try block. + # Previously it exited 1 with empty stdout; do_cli's UserException handler must emit JSON. + result = json.loads(self._run_expecting(MissingBuildMethodException("no build method"))[0]) + + self.assertEqual(result["status"], "failure") + self.assertEqual(result["error"]["type"], "MissingBuildMethodException") + + def test_json_failure_for_bare_exception(self): + # Bare-Exception template errors (e.g. InvalidLayerReference) are not UserException; + # the broad handler must still emit JSON so agent consumers never get empty stdout. + result = json.loads(self._run_expecting(RuntimeError("unexpected template error"))[0]) + + self.assertEqual(result["status"], "failure") + self.assertEqual(result["error"]["type"], "RuntimeError") + + class TestGetModeValueFromEnvvar(TestCase): def setUp(self): self.original = os.environ.copy() diff --git a/tests/unit/commands/samconfig/test_samconfig.py b/tests/unit/commands/samconfig/test_samconfig.py index 86f189a3f1..fb859a2a19 100644 --- a/tests/unit/commands/samconfig/test_samconfig.py +++ b/tests/unit/commands/samconfig/test_samconfig.py @@ -166,6 +166,7 @@ def test_build(self, do_cli_mock): True, False, None, + "text", ) @patch("samcli.commands.build.command.do_cli") @@ -228,6 +229,7 @@ def test_build_with_no_use_container(self, do_cli_mock): False, False, None, + "text", ) @patch("samcli.commands.build.command.do_cli") @@ -289,6 +291,7 @@ def test_build_with_no_use_container_option(self, do_cli_mock): False, False, None, + "text", ) @patch("samcli.commands.build.command.do_cli") @@ -351,6 +354,7 @@ def test_build_with_no_use_container_override(self, do_cli_mock): False, False, None, + "text", ) @patch("samcli.commands.build.command.do_cli") @@ -414,6 +418,7 @@ def test_build_with_no_cached_override(self, do_cli_mock): False, False, None, + "text", ) @patch("samcli.commands.build.command.do_cli") @@ -474,6 +479,7 @@ def test_build_with_container_env_vars(self, do_cli_mock): False, False, None, + "text", ) @patch("samcli.commands.build.command.do_cli") @@ -533,6 +539,7 @@ def test_build_with_build_images(self, do_cli_mock): False, False, None, + "text", ) @patch("samcli.commands.local.invoke.cli.do_cli")