-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat: add --output json flag (sam build) #9136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
54aed55
2fd6125
b104652
c5a0769
4a25be1
4e8752f
127c008
de503bf
514edc2
0bd5c26
5d85acd
c33c005
9a951d2
588ff68
985e7a4
f46d027
90dd7f6
0b14a08
050e1ec
20b09c0
a64f926
8c485b0
5ad0867
77f417f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two interactive prompts in the build path write to stdout, so they interleave with the JSON document and make it unparseable:
Confirmed The rest of the CLI already keeps human-facing output off stdout — if click.confirm(
f"\nBuilding functions with {config.language} inside containers needs "
...
err=True,
):Worth a test that runs
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed using your suggested approach added
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this not a problem with the |
||
| 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]: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I get we're sending this to stderr, but it's not clear to me why and in what scenario that's useful.