Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 57 additions & 10 deletions samcli/commands/build/build_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

import copy
import itertools
import json
import logging
import os
import pathlib
import shutil
import sys
from collections import Counter
from typing import Any, Dict, List, Optional, Tuple

Expand Down Expand Up @@ -104,6 +106,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
Expand Down Expand Up @@ -213,6 +216,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 = output

def __enter__(self) -> "BuildContext":
self.set_up()
Expand Down Expand Up @@ -269,6 +273,9 @@ def get_resources_to_build(self):

def run(self) -> None:
"""Runs the building process by creating an ApplicationBuilder."""
if self._output == "json":
logging.getLogger("samcli").setLevel(logging.WARNING)

if self._is_sam_template():
SamApiProvider.check_implicit_api_resource_ids(self.stacks)

Expand Down Expand Up @@ -321,8 +328,6 @@ def run(self) -> None:

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.
Expand All @@ -336,17 +341,43 @@ 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")
if self._output == "json":
result = {
"status": "success",
"build_dir": build_dir_in_success_message,
"template_file": output_template_path_in_success_message,
"resources": [
{
"logical_id": f.full_path,
"runtime": f.runtime,
"architecture": f.architectures[0] if f.architectures else None,
}
for f in self.get_resources_to_build().functions

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BUG] The JSON success payload's resources list is built only from self.get_resources_to_build().functions, dropping layers. ResourcesToBuildCollector exposes both functions and layers (see samcli/lib/providers/provider.py:229), and sam build builds both. A template that contains only AWS::Serverless::LayerVersion resources — or a mixed template — will produce a JSON success payload with an incomplete (or empty) resources array, which is misleading to any downstream tooling that iterates the list.

Include layers, e.g.:

"resources": [
   {
       "logical_id": f.full_path,
       "type": "function",
       "runtime": f.runtime,
       "architecture": f.architectures[0] if f.architectures else None,
   }
   for f in self.get_resources_to_build().functions
] + [
   {
       "logical_id": layer.full_path,
       "type": "layer",
       "compatible_runtimes": layer.compatible_runtimes,
   }
   for layer in self.get_resources_to_build().layers
],

Also note that f.full_path is not strictly a CloudFormation logical id for nested-stack resources (it uses ParentStack/ChildLogicalId notation); consumers reading the field named logical_id may not expect the slash-separated form shown by full_path. Either rename the field to resource_id/path, or emit the raw logical id.

],
}
click.echo(json.dumps(result, indent=2))
else:
click.secho("\nBuild Succeeded", fg="green")
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")
except FunctionNotFound as function_not_found_ex:
caught_exception = function_not_found_ex

if self._output == "json":
error_result = {
"status": "failure",
"error": {
"type": "FunctionNotFound",
"message": str(function_not_found_ex),
},
}
click.echo(json.dumps(error_result, indent=2))
sys.exit(1)
raise UserException(
str(function_not_found_ex), wrapped_from=function_not_found_ex.__class__.__name__
) from function_not_found_ex
Expand All @@ -360,6 +391,22 @@ def run(self) -> None:
) as ex:
caught_exception = ex

if self._output == "json":
deep_wrap = getattr(ex, "wrapped_from", None)
error_type = deep_wrap if deep_wrap else ex.__class__.__name__
error_result = {
"status": "failure",
"error": {
"type": error_type,
"message": str(ex),
},
}
resource_name = getattr(ex, "resource_name", None)
if resource_name:
error_result["error"]["resource"] = resource_name
click.echo(json.dumps(error_result, indent=2))
sys.exit(1)

click.secho("\nBuild Failed", fg="red")

# Some Exceptions have a deeper wrapped exception that needs to be surfaced
Expand Down
11 changes: 11 additions & 0 deletions samcli/commands/build/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@
@template_option_without_build
@parameter_override_option
@docker_common_options
@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),
)
@cli_framework_options
@aws_creds_options
@click.argument("resource_logical_id", required=False)
Expand Down Expand Up @@ -167,6 +174,7 @@ def cli(
mount_symlinks: Optional[bool],
use_buildkit: Optional[bool],
language_extensions: Optional[bool],
output: str,
) -> None:
"""
`sam build` command entry point
Expand Down Expand Up @@ -201,6 +209,7 @@ def cli(
mount_symlinks,
use_buildkit,
language_extensions,
output,
) # pragma: no cover


Expand Down Expand Up @@ -230,6 +239,7 @@ 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
Expand Down Expand Up @@ -272,6 +282,7 @@ def do_cli( # pylint: disable=too-many-locals, too-many-statements
mount_symlinks=mount_symlinks,
use_buildkit=use_buildkit,
language_extensions=language_extensions,
output=output,
) as ctx:
ctx.run()

Expand Down
2 changes: 1 addition & 1 deletion samcli/commands/build/core/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

EXTENSION_OPTIONS: List[str] = ["hook_name", "skip_prepare_infra"]

BUILD_STRATEGY_OPTIONS: List[str] = ["parallel", "exclude", "manifest", "cached", "build_in_source"]
BUILD_STRATEGY_OPTIONS: List[str] = ["parallel", "exclude", "manifest", "cached", "build_in_source", "output"]

ARTIFACT_LOCATION_OPTIONS: List[str] = [
"build_dir",
Expand Down
30 changes: 17 additions & 13 deletions samcli/lib/build/app_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,19 +807,23 @@ def _build_function( # pylint: disable=R1710
specified_workflow=specified_workflow if supported_specified_workflow else None,
)

return self._build_function_in_process(
config,
code_dir,
artifact_dir,
scratch_dir,
manifest_path,
runtime,
architecture,
options,
dependencies_dir,
download_dependencies,
self._combine_dependencies,
)
try:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BUG] The new try/except BuildError only wraps _build_function_in_process. The container path a few lines above (self._build_function_on_container(...)) is not wrapped, so ex.resource_name is never set when a function fails to build inside a container. That path can raise BuildError subclasses such as DockerBuildFailed, DockerfileOutSideOfContext, and DockerConnectionError (see raise DockerBuildFailed(...) at lines 440–493 of this file).

Consequence: for sam build --use-container — a very common invocation — the JSON failure payload will be missing the resource field that the PR description specifically calls out as the value-add over the existing text output. Wrap both branches (or set resource_name around the outer with osutils.mkdir_temp() block) so container failures also surface the failing resource:

try:
   if self._container_manager:
       return self._build_function_on_container(...)
   return self._build_function_in_process(...)
except BuildError as ex:
   ex.resource_name = function_name
   raise

Layer builds (_build_layer) exhibit the same gap and should be considered as well.

return self._build_function_in_process(
config,
code_dir,
artifact_dir,
scratch_dir,
manifest_path,
runtime,
architecture,
options,
dependencies_dir,
download_dependencies,
self._combine_dependencies,
)
except BuildError as ex:
ex.resource_name = function_name
raise

# pylint: disable=fixme
# FIXME: we need to throw an exception here, packagetype could be something else
Expand Down
5 changes: 4 additions & 1 deletion samcli/lib/build/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
Build Related Exceptions.
"""

from typing import Optional

from samcli.commands.exceptions import UserException


Expand All @@ -15,8 +17,9 @@ def __init__(self, container_name: str, error_msg: str) -> None:


class BuildError(Exception):
def __init__(self, wrapped_from: str, msg: str) -> None:
def __init__(self, wrapped_from: str, msg: str, resource_name: Optional[str] = None) -> None:
self.wrapped_from = wrapped_from
self.resource_name = resource_name
Exception.__init__(self, msg)


Expand Down