From e49d7d45f3f0b64412de29008d3490e73e7b029d Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Wed, 21 Oct 2026 10:14:55 -0700 Subject: [PATCH 01/23] feat(build): Add sam build support for MicrovmImage Add sam build support for AWS::Serverless::MicrovmImage and AWS::Lambda::MicrovmImage. sam build runs docker build on the resource's source directory and tags the image :latest for local testing. The built template's code path is not rewritten to a build artifact: the local image is not the deployment artifact. The source directory is what gets zipped and uploaded, and the MicroVM service builds it remotely. move_template only rebases the relative path. - SamMicrovmImageProvider extracts images from the template, maps CpuConfigurations[0].Architecture (only ARM_64 is supported), and accepts CodeArtifact as either a string or {Uri: ...} - MicrovmImageBuildDefinition is wired into the build graph and all build strategies; it is not persisted to build.toml - Explicit targets (sam build MyImage) fail on an unsupported architecture; the build-all path warns and skips - --output json reports built MicrovmImages --- samcli/commands/build/build_context.py | 102 ++++++- samcli/commands/build/command.py | 4 +- samcli/lib/build/app_builder.py | 127 ++++++++- samcli/lib/build/build_graph.py | 74 ++++- samcli/lib/build/build_strategy.py | 83 +++++- samcli/lib/providers/provider.py | 63 +++++ samcli/lib/providers/sam_base_provider.py | 4 + .../providers/sam_microvm_image_provider.py | 149 ++++++++++ samcli/lib/utils/resources.py | 5 + schema/samcli.json | 2 +- .../buildcmd/test_build_cmd_microvm_image.py | 111 ++++++++ .../testdata/buildcmd/MicrovmImage/Dockerfile | 9 + .../testdata/buildcmd/MicrovmImage/app.py | 17 ++ .../buildcmd/template_microvm_image.yaml | 16 ++ .../commands/buildcmd/test_build_context.py | 199 ++++++++++++- .../test_build_context_web_functions.py | 3 + .../unit/commands/local/lib/test_provider.py | 41 +++ .../lib/test_sam_microvm_image_provider.py | 266 ++++++++++++++++++ .../unit/lib/build_module/test_app_builder.py | 89 +++++- .../unit/lib/build_module/test_build_graph.py | 52 +++- .../lib/build_module/test_build_strategy.py | 50 +++- 21 files changed, 1426 insertions(+), 40 deletions(-) create mode 100644 samcli/lib/providers/sam_microvm_image_provider.py create mode 100644 tests/integration/buildcmd/test_build_cmd_microvm_image.py create mode 100644 tests/integration/testdata/buildcmd/MicrovmImage/Dockerfile create mode 100644 tests/integration/testdata/buildcmd/MicrovmImage/app.py create mode 100644 tests/integration/testdata/buildcmd/template_microvm_image.yaml create mode 100644 tests/unit/commands/local/lib/test_sam_microvm_image_provider.py diff --git a/samcli/commands/build/build_context.py b/samcli/commands/build/build_context.py index 03a11be2e3b..921304f08ae 100644 --- a/samcli/commands/build/build_context.py +++ b/samcli/commands/build/build_context.py @@ -62,6 +62,7 @@ from samcli.lib.providers.provider import ( BuildableFunction, LayerVersion, + MicrovmImage, ResourcesToBuildCollector, Stack, WebFunction, @@ -70,6 +71,7 @@ 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 +from samcli.lib.providers.sam_microvm_image_provider import SamMicrovmImageProvider from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider from samcli.lib.providers.sam_web_function_provider import SamWebFunctionProvider, check_no_web_functions_in_foreach from samcli.lib.telemetry.event import EventName, EventTracker, UsedFeature @@ -225,6 +227,7 @@ def __init__( self._function_provider: Optional[SamFunctionProvider] = None self._web_function_provider: Optional[SamWebFunctionProvider] = None self._layer_provider: Optional[SamLayerProvider] = None + self._microvm_image_provider: Optional[SamMicrovmImageProvider] = None self._container_manager: Optional[ContainerManager] = None self._stacks: List[Stack] = [] self._locate_layer_nested = locate_layer_nested @@ -267,6 +270,7 @@ def set_up(self) -> None: check_no_web_functions_in_foreach(self.stacks) self._web_function_provider = SamWebFunctionProvider(self.stacks, self._use_raw_codeuri) self._layer_provider = SamLayerProvider(self.stacks, self._use_raw_codeuri) + self._microvm_image_provider = SamMicrovmImageProvider(self.stacks, self._use_raw_codeuri) template_dir = str(pathlib.Path(self._template_file).resolve().parent) if not self._base_dir: @@ -1142,6 +1146,9 @@ def _resolve_resource_full_paths(self, resource_identifier: Optional[str]) -> Op layer = self.layer_provider.get(resource_identifier) if self.layer_provider else None if layer: return [layer.full_path] + image = self.microvm_image_provider.get(resource_identifier) if self.microvm_image_provider else None + if image: + return [image.full_path] return [resource_identifier] @staticmethod @@ -1177,16 +1184,25 @@ def _print_build_success( if self._output is OutputOption.json: # Emit absolute paths so a machine consumer never has to guess the process cwd. The # relpath transform below is only a human-readability affordance for the text banner. - 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 - ] + 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 + ] + + [ + { + "resource_id": image.full_path, + "type": "microvm_image", + "architecture": image.architecture, + } + for image in resources_to_build.microvm_images + ] + ) click.echo( json.dumps( { @@ -1345,6 +1361,11 @@ def layer_provider(self) -> SamLayerProvider: # same as function_provider() return self._layer_provider # type: ignore + @property + def microvm_image_provider(self) -> SamMicrovmImageProvider: + # same as function_provider() + return self._microvm_image_provider # type: ignore + @property def build_dir(self) -> str: return self._build_dir @@ -1393,7 +1414,7 @@ def use_base_dir(self) -> bool: def resources_to_build(self) -> ResourcesToBuildCollector: """ Function return resources that should be build by current build command. This function considers - Lambda Functions and Layers with build method as buildable resources. + Lambda Functions, Layers, and MicroVM Images as buildable resources. Returns ------- ResourcesToBuildCollector @@ -1437,22 +1458,26 @@ def collect_build_resources(self, resource_identifier: str) -> ResourcesToBuildC if not result.functions: self._collect_single_web_function(resource_identifier, result) self._collect_single_buildable_layer(resource_identifier, result) + self._collect_single_microvm_image(resource_identifier, result) - if not result.functions and not result.layers: - # Collect all functions and layers that are not inline + if not result.functions and not result.layers and not result.microvm_images: + # Collect all functions, layers, and microvm images that are buildable all_resources = [func.name for func in self.function_provider.get_all() if not func.inlinecode] all_resources.extend([web_function.name for web_function in self._get_web_functions()]) all_resources.extend([layer.name for layer in self.layer_provider.get_all()]) + all_resources.extend([image.name for image in self.microvm_image_provider.get_all()]) available_resource_message = ( f"{resource_identifier} not found. Possible options in your " f"template: {all_resources}" ) LOG.info(available_resource_message) - raise ResourceNotFound(f"Unable to find a function or layer with name '{resource_identifier}'") + raise ResourceNotFound( + f"Unable to find a function, layer, or MicroVM image with name '{resource_identifier}'" + ) return result def collect_all_build_resources(self) -> ResourcesToBuildCollector: - """Collect all buildable resources. Including Lambda functions and layers. + """Collect all buildable resources. Including Lambda functions, layers, and MicroVM images. Returns ------- @@ -1482,6 +1507,16 @@ def collect_all_build_resources(self) -> ResourcesToBuildCollector: if (layer.name not in excludes) and BuildContext.is_layer_buildable(layer) ] ) + buildable_images = [] + for image in self.microvm_image_provider.get_all(): + if image.name in excludes: + continue + buildable, non_buildable_reason = BuildContext.is_microvm_image_buildable(image) + if not buildable: + LOG.warning("Skipping MicrovmImage %s: %s", image.full_path, non_buildable_reason) + continue + buildable_images.append(image) + result.add_microvm_images(buildable_images) return result @property @@ -1547,6 +1582,33 @@ def _collect_single_buildable_layer( resource_collector.add_layer(layer) + def _collect_single_microvm_image( + self, resource_identifier: str, resource_collector: ResourcesToBuildCollector + ) -> None: + """ + Populate resource_collector with MicrovmImage with provided identifier. + + Parameters + ---------- + resource_identifier : str + Resource identifier for the MicrovmImage to be built + resource_collector : ResourcesToBuildCollector + Collector to add the MicrovmImage to + """ + image = self.microvm_image_provider.get(resource_identifier) + if not image: + return + # Hard fail only for truly unbuildable cases (unsupported architecture) + if image.architecture is None: + raise BuildError( + wrapped_from="UnsupportedArchitectureError", + msg=f"MicrovmImage '{resource_identifier}' is not buildable: " + "unsupported architecture; only ARM_64 is supported by the MicroVM service.", + ) + # For explicitly targeted resources, add unconditionally (matching function/layer behavior). + # SkipBuild and .zip filtering only applies on the build-all path (collect_all_build_resources). + resource_collector.add_microvm_image(image) + @staticmethod def is_layer_buildable(layer: LayerVersion): # if build method is not specified, it is not buildable @@ -1563,6 +1625,16 @@ def is_layer_buildable(layer: LayerVersion): return False return True + @staticmethod + def is_microvm_image_buildable(image: MicrovmImage): + if isinstance(image.code_uri, str) and image.code_uri.endswith(".zip"): + return False, "CodeUri points to a .zip file, not a buildable source directory." + if image.metadata and image.metadata.get("SkipBuild"): + return False, "Metadata.SkipBuild is set." + if image.architecture is None: + return False, "unsupported architecture; only ARM_64 is supported by the MicroVM service." + return True, "" + _EXCLUDE_WARNING_MESSAGE = "Resource expected to be built, but marked as excluded.\nBuilding anyways..." def _check_exclude_warning(self) -> None: diff --git a/samcli/commands/build/command.py b/samcli/commands/build/command.py index 2fe020aebca..c4a6954c0b2 100644 --- a/samcli/commands/build/command.py +++ b/samcli/commands/build/command.py @@ -43,7 +43,7 @@ LOG = logging.getLogger(__name__) HELP_TEXT = """ - Build AWS serverless function code. + Build AWS serverless function code and MicroVM images. """ DESCRIPTION = """ @@ -56,6 +56,8 @@ 2. AWS::Lambda::Function\n 3. AWS::Serverless::LayerVersion\n 4. AWS::Lambda::LayerVersion\n + 5. AWS::Serverless::MicrovmImage\n + 6. AWS::Lambda::MicrovmImage\n \b Supported Runtimes ------------------ diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index a15d546fc3f..f106671c01f 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -19,7 +19,12 @@ from aws_lambda_builders.exceptions import LambdaBuilderError from samcli.commands._utils.experimental import get_enabled_experimental_flags -from samcli.lib.build.build_graph import BuildGraph, FunctionBuildDefinition, LayerBuildDefinition +from samcli.lib.build.build_graph import ( + BuildGraph, + FunctionBuildDefinition, + LayerBuildDefinition, + MicrovmImageBuildDefinition, +) from samcli.lib.build.build_strategy import ( BuildStrategy, CachedOrIncrementalBuildStrategyWrapper, @@ -60,9 +65,11 @@ AWS_CLOUDFORMATION_STACK, AWS_LAMBDA_FUNCTION, AWS_LAMBDA_LAYERVERSION, + AWS_LAMBDA_MICROVMIMAGE, AWS_SERVERLESS_APPLICATION, AWS_SERVERLESS_FUNCTION, AWS_SERVERLESS_LAYERVERSION, + AWS_SERVERLESS_MICROVMIMAGE, AWS_SERVERLESS_WEBFUNCTION, ) from samcli.lib.utils.stream_writer import StreamWriter @@ -235,7 +242,12 @@ def build(self) -> ApplicationBuildResult: warn_cross_architecture_build(functions, container_runtime) build_strategy: BuildStrategy = DefaultBuildStrategy( - build_graph, self._build_dir, self._build_function, self._build_layer, self._cached + build_graph, + self._build_dir, + self._build_function, + self._build_layer, + self._build_microvm_image, + self._cached, ) if self._parallel: @@ -267,7 +279,9 @@ def build(self) -> ApplicationBuildResult: bool(self._container_manager), ) - return ApplicationBuildResult(build_graph, build_strategy.build()) + result = ApplicationBuildResult(build_graph, build_strategy.build()) + + return result def _get_build_graph( self, inline_env_vars: Optional[Dict] = None, env_vars_file: Optional[str] = None @@ -280,6 +294,7 @@ def _get_build_graph( build_graph = BuildGraph(self._build_dir) functions = self._resources_to_build.functions layers = self._resources_to_build.layers + microvm_images = self._resources_to_build.microvm_images file_env_vars = {} if env_vars_file: try: @@ -321,6 +336,14 @@ def _get_build_graph( ) build_graph.put_layer_build_definition(layer_build_details, layer) + for microvm_image in microvm_images: + microvm_build_details = MicrovmImageBuildDefinition( + microvm_image.full_path, + microvm_image.code_uri, + cast(str, microvm_image.architecture), + ) + build_graph.put_microvm_image_build_definition(microvm_build_details, microvm_image) + build_graph.clean_redundant_definitions_and_update(not self._is_building_specific_resource) return build_graph @@ -388,6 +411,17 @@ def update_template( store_path = os.path.relpath(absolute_output_path, original_dir) if has_build_artifact: + # For MicrovmImage: if CodeUri is an intrinsic, replace it with the resolved literal + # from stack.resources so that move_template can correctly rebase the path. + if resource_type in (AWS_SERVERLESS_MICROVMIMAGE, AWS_LAMBDA_MICROVMIMAGE): + code_prop = "CodeUri" if resource_type == AWS_SERVERLESS_MICROVMIMAGE else "CodeArtifact" + code_value = properties.get(code_prop) + if code_value and not isinstance(code_value, str): + resolved_props = normalized_resources.get(logical_id, {}).get("Properties", {}) + resolved_code = resolved_props.get(code_prop) + if isinstance(resolved_code, str): + properties[code_prop] = resolved_code + ApplicationBuilder._update_built_resource( built_artifacts[full_path], properties, resource_type or "", store_path ) @@ -403,6 +437,11 @@ def update_template( @staticmethod def _update_built_resource(path: str, resource_properties: Dict, resource_type: str, absolute_path: str) -> None: + # MicrovmImage: Docker image is local-only for testing, built template is not updated. + # Deployment artifact is the zip of source directory (handled by sam package), not the Docker image. + # Intrinsic CodeUri is resolved to a literal in update_template before reaching here. + if resource_type in (AWS_SERVERLESS_MICROVMIMAGE, AWS_LAMBDA_MICROVMIMAGE): + return if resource_type == AWS_SERVERLESS_FUNCTION and resource_properties.get("PackageType", ZIP) == ZIP: resource_properties["CodeUri"] = absolute_path if resource_type == AWS_SERVERLESS_WEBFUNCTION: @@ -973,6 +1012,88 @@ def _get_working_directory_path( return working_directory + def _build_microvm_image(self, full_path: str, code_uri: str, architecture: str) -> str: + """ + Build a Docker image for a MicrovmImage resource. + + Uses CodeUri as the Docker build context. Dockerfile must be in that directory. + Tags the image as :latest, so that + MicrovmImage resources with the same logical ID in different nested stacks don't collide + on the same local Docker tag. + Does NOT update the built template. + + Parameters + ---------- + full_path : str + Stack-path-qualified unique identifier of the MicrovmImage resource (see + MicrovmImage.full_path), e.g. "MyImage" or "ChildStack/MyImage" + code_uri : str + Local path to the source directory containing the Dockerfile + architecture : str + CPU architecture for the Docker build platform + + Returns + ------- + str + The Docker image tag that was built + """ + LOG.info("Building MicrovmImage %s", full_path) + + docker_context_dir = pathlib.Path(self._base_dir, code_uri).resolve() + dockerfile_path = docker_context_dir / "Dockerfile" + + if not dockerfile_path.exists(): + raise DockerBuildFailed( + f"Dockerfile not found in CodeUri directory '{docker_context_dir}' for MicrovmImage '{full_path}'." + ) + + docker_tag = f"{full_path.replace('/', '-').lower()}:latest" + + build_args = { + "path": str(docker_context_dir), + "dockerfile": "Dockerfile", + "tag": docker_tag, + "buildargs": {}, + "platform": get_docker_platform(architecture), + "rm": True, + } + + try: + if not self._image_build_client: + if self._use_buildkit: + container_client = self._container_client + engine_type = container_client.get_runtime_type() + + is_available, error_msg = CLIBuildClient.is_available(engine_type) + if not is_available: + raise BuildkitNotAvailableException(error_msg) + + self._image_build_client = CLIBuildClient(engine_type=engine_type) + LOG.debug(f"Using CLIBuildClient with engine_type {engine_type}") + else: + self._image_build_client = SDKBuildClient(self._container_client) + LOG.debug("Using SDKBuildClient") + + build_logs = self._image_build_client.build_image(**build_args) # type: ignore[arg-type] + LOG.debug("MicrovmImage built for %s", full_path) + except docker.errors.BuildError as ex: + LOG.error("Failed building MicrovmImage %s", full_path) + self._stream_lambda_image_build_logs(ex.build_log, full_path, False) + raise DockerBuildFailed(str(ex)) from ex + except docker.errors.APIError as e: + if self._container_client.is_dockerfile_error(e): + raise DockerfileOutSideOfContext(e.explanation) from e + raise + + try: + self._stream_lambda_image_build_logs(build_logs, full_path) # type: ignore[arg-type] + except docker.errors.APIError as e: + if self._container_client.is_dockerfile_error(e): + raise DockerfileOutSideOfContext(e.explanation) from e + raise + + return docker_tag + def _build_function_in_process( self, config: CONFIG, diff --git a/samcli/lib/build/build_graph.py b/samcli/lib/build/build_graph.py index a8eeafebaca..c7c7dac9b83 100644 --- a/samcli/lib/build/build_graph.py +++ b/samcli/lib/build/build_graph.py @@ -17,7 +17,7 @@ from samcli.commands._utils.experimental import ExperimentalFlag, is_experimental_enabled from samcli.lib.build.exceptions import InvalidBuildGraphException -from samcli.lib.providers.provider import BuildableFunction, LayerVersion, get_build_entry_point +from samcli.lib.providers.provider import BuildableFunction, LayerVersion, MicrovmImage, get_build_entry_point from samcli.lib.samlib.resource_metadata_normalizer import ( SAM_IS_NORMALIZED, SAM_RESOURCE_ID_KEY, @@ -212,6 +212,7 @@ def __init__(self, build_dir: str) -> None: self._filepath = Path(build_dir).parent.joinpath(DEFAULT_BUILD_GRAPH_FILE_NAME) self._function_build_definitions: List["FunctionBuildDefinition"] = [] self._layer_build_definitions: List["LayerBuildDefinition"] = [] + self._microvm_image_build_definitions: List["MicrovmImageBuildDefinition"] = [] self._atomic_read() def get_function_build_definitions(self) -> Tuple["FunctionBuildDefinition", ...]: @@ -220,6 +221,9 @@ def get_function_build_definitions(self) -> Tuple["FunctionBuildDefinition", ... def get_layer_build_definitions(self) -> Tuple["LayerBuildDefinition", ...]: return tuple(self._layer_build_definitions) + def get_microvm_image_build_definitions(self) -> Tuple["MicrovmImageBuildDefinition", ...]: + return tuple(self._microvm_image_build_definitions) + def get_function_build_definition_with_full_path( self, function_full_path: str ) -> Optional["FunctionBuildDefinition"]: @@ -315,11 +319,34 @@ def put_layer_build_definition(self, layer_build_definition: "LayerBuildDefiniti layer_build_definition.layer = layer self._layer_build_definitions.append(layer_build_definition) + def put_microvm_image_build_definition( + self, microvm_image_build_definition: "MicrovmImageBuildDefinition", microvm_image: MicrovmImage + ) -> None: + """ + Puts the newly read MicroVM image build definition into existing build graph. + + Unlike Function/Layer, MicrovmImage definitions are not persisted to build.toml (Docker has its own + layer cache), so there are no pre-existing definitions to match against. Each definition is always new. + + Parameters + ---------- + microvm_image_build_definition: MicrovmImageBuildDefinition + MicroVM image build definition which is newly read from template.yaml file + microvm_image: MicrovmImage + MicroVM image details for this build definition + """ + microvm_image_build_definition.add_microvm_image(microvm_image) + self._microvm_image_build_definitions.append(microvm_image_build_definition) + def clean_redundant_definitions_and_update(self, persist: bool) -> None: """ Removes build definitions which doesn't have any function in it, which means these build definitions are no longer used, and they can be deleted + Note: MicrovmImage build definitions are intentionally not cleaned up here. Unlike Function/Layer, + they are never read from build.toml, so every put_microvm_image_build_definition() call always + sets microvm_image before appending -- there is no code path that produces an empty definition. + If persist parameter is given True, build graph is written to .aws-sam/build.toml file """ self._function_build_definitions[:] = [ @@ -711,3 +738,48 @@ def __eq__(self, other: Any) -> bool: and self.architecture == other.architecture and self.resource_type == other.resource_type ) + + +class MicrovmImageBuildDefinition(AbstractBuildDefinition): + """ + MicrovmImageBuildDefinition holds information about each unique MicroVM image build. + Each MicrovmImage is built via docker build from its CodeUri directory. + """ + + def __init__( + self, + full_path: str, + code_uri: str, + architecture: str, + source_hash: str = "", + manifest_hash: str = "", + env_vars: Optional[Dict] = None, + ) -> None: + super().__init__(source_hash, manifest_hash, env_vars, architecture) + self.full_path = full_path + self.code_uri = code_uri + self.microvm_image: Optional["MicrovmImage"] = None + + def add_microvm_image(self, microvm_image: "MicrovmImage") -> None: + self.microvm_image = microvm_image + + def get_resource_full_paths(self) -> str: + """Returns the MicroVM image's full path""" + if not self.microvm_image: + return "" + return self.microvm_image.full_path + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, MicrovmImageBuildDefinition): + return False + return ( + self.full_path == other.full_path + and self.code_uri == other.code_uri + and self.architecture == other.architecture + ) + + def __hash__(self) -> int: + return hash((self.full_path, self.code_uri, self.architecture)) + + def __str__(self) -> str: + return f"MicrovmImageBuildDefinition({self.full_path}, {self.code_uri}, {self.architecture})" diff --git a/samcli/lib/build/build_strategy.py b/samcli/lib/build/build_strategy.py index 4b88553b9e5..d3e13b3fefb 100644 --- a/samcli/lib/build/build_strategy.py +++ b/samcli/lib/build/build_strategy.py @@ -19,6 +19,7 @@ BuildGraph, FunctionBuildDefinition, LayerBuildDefinition, + MicrovmImageBuildDefinition, ) from samcli.lib.build.dependency_hash_generator import DependencyHashGenerator from samcli.lib.build.exceptions import ( @@ -37,9 +38,10 @@ LOG = logging.getLogger(__name__) -# type definition which can be used in generic types for both FunctionBuildDefinition & LayerBuildDefinition -FunctionOrLayerBuildDefinition = TypeVar( - "FunctionOrLayerBuildDefinition", FunctionBuildDefinition, LayerBuildDefinition +# type definition which can be used in generic types for FunctionBuildDefinition, LayerBuildDefinition, +# and MicrovmImageBuildDefinition +BuildDefinitionType = TypeVar( + "BuildDefinitionType", FunctionBuildDefinition, LayerBuildDefinition, MicrovmImageBuildDefinition ) # Build failures that are attributable to specific resources. When one of these escapes a strategy @@ -95,7 +97,7 @@ def clean_redundant_folders(base_dir: str, uuids: Set[str]) -> None: class BuildStrategy(ABC): """ Base class for BuildStrategy - Keeps basic implementation of build, build_functions and build_layers + Keeps basic implementation of build, build_functions, build_layers, and build_microvm_images """ def __init__(self, build_graph: BuildGraph) -> None: @@ -109,12 +111,13 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: def build(self) -> Dict[str, str]: """ - Builds all functions and layers in the given build graph + Builds all functions, layers, and MicrovmImages in the given build graph """ result = {} with self: result.update(self._build_layers(self._build_graph)) result.update(self._build_functions(self._build_graph)) + result.update(self._build_microvm_images(self._build_graph)) return result @@ -152,10 +155,28 @@ def build_single_layer_definition(self, layer_definition: LayerBuildDefinition) build location as value """ + def _build_microvm_images(self, build_graph: BuildGraph) -> Dict[str, str]: + """ + Iterates through build graph and runs each unique build for MicrovmImage resources + """ + microvm_build_results = {} + for microvm_definition in build_graph.get_microvm_image_build_definitions(): + microvm_build_results.update(self.build_single_microvm_image_definition(microvm_definition)) + + return microvm_build_results + + @abstractmethod + def build_single_microvm_image_definition(self, microvm_definition: MicrovmImageBuildDefinition) -> Dict[str, str]: + """ + Builds single MicrovmImage definition and returns dictionary which contains MicrovmImage full path as key, + Docker image tag as value + """ + class DefaultBuildStrategy(BuildStrategy): """ - Default build strategy, loops over given build graph for each function and layer, and builds each of them one by one + Default build strategy, loops over given build graph for each function, layer, and MicrovmImage, + and builds each of them one by one """ def __init__( @@ -166,12 +187,14 @@ def __init__( [str, str, Optional[str], str, str, str, Optional[str], str, dict, dict, Optional[str], bool], str ], build_layer: Callable[[str, str, str, List[str], str, str, dict, Optional[str], bool, Optional[Dict]], str], + build_microvm_image: Callable[[str, str, str], str], cached: bool = False, ) -> None: super().__init__(build_graph) self._build_dir = build_dir self._build_function = build_function self._build_layer = build_layer + self._build_microvm_image = build_microvm_image self._cached = cached def build_single_function_definition(self, build_definition: FunctionBuildDefinition) -> Dict[str, str]: @@ -302,6 +325,29 @@ def _do_build_single_layer_definition(self, layer_definition: LayerBuildDefiniti ) } + def build_single_microvm_image_definition(self, microvm_definition: MicrovmImageBuildDefinition) -> Dict[str, str]: + """ + Build a single MicrovmImage definition via docker build. + """ + microvm_image = microvm_definition.microvm_image + if not microvm_image or not microvm_image.architecture: + LOG.warning( + "Skipping MicrovmImage build: missing image or architecture for definition %s", + microvm_definition.code_uri, + ) + return {} + with _attribute_build_failure_to([microvm_image.full_path]): + LOG.info( + "Building MicrovmImage: %s from %s", + microvm_image.full_path, + microvm_definition.code_uri, + ) + + result = self._build_microvm_image( + microvm_image.full_path, microvm_definition.code_uri, microvm_image.architecture + ) + return {microvm_image.full_path: result} + class CachedBuildStrategy(BuildStrategy): """ @@ -444,6 +490,13 @@ def _clean_redundant_cached(self) -> None: uuids.update({ld.uuid for ld in self._build_graph.get_layer_build_definitions()}) clean_redundant_folders(self._cache_dir, uuids) + def build_single_microvm_image_definition(self, microvm_definition: MicrovmImageBuildDefinition) -> Dict[str, str]: + """ + Builds single MicrovmImage definition. Delegates to the underlying build strategy since + Docker has its own layer caching. + """ + return self._delegate_build_strategy.build_single_microvm_image_definition(microvm_definition) + class ParallelBuildStrategy(BuildStrategy): """ @@ -472,10 +525,15 @@ def _build_functions(self, build_graph: BuildGraph) -> Dict[str, str]: self.build_single_function_definition, build_graph.get_function_build_definitions() ) + def _build_microvm_images(self, build_graph: BuildGraph) -> Dict[str, str]: + return self._run_builds_async( + self.build_single_microvm_image_definition, build_graph.get_microvm_image_build_definitions() + ) + @staticmethod def _run_builds_async( - build_method: Callable[[FunctionOrLayerBuildDefinition], Dict[str, str]], - build_definitions: Tuple[FunctionOrLayerBuildDefinition, ...], + build_method: Callable[[BuildDefinitionType], Dict[str, str]], + build_definitions: Tuple[BuildDefinitionType, ...], ) -> Dict[str, str]: """Builds given list of build definitions in async and return the result""" if not build_definitions: @@ -497,6 +555,9 @@ def build_single_layer_definition(self, layer_definition: LayerBuildDefinition) def build_single_function_definition(self, build_definition: FunctionBuildDefinition) -> Dict[str, str]: return self._delegate_build_strategy.build_single_function_definition(build_definition) + def build_single_microvm_image_definition(self, microvm_definition: MicrovmImageBuildDefinition) -> Dict[str, str]: + return self._delegate_build_strategy.build_single_microvm_image_definition(microvm_definition) + class IncrementalBuildStrategy(BuildStrategy): """ @@ -542,6 +603,9 @@ def build_single_layer_definition(self, layer_definition: LayerBuildDefinition) ) return self._delegate_build_strategy.build_single_layer_definition(layer_definition) + def build_single_microvm_image_definition(self, microvm_definition: MicrovmImageBuildDefinition) -> Dict[str, str]: + return self._delegate_build_strategy.build_single_microvm_image_definition(microvm_definition) + def _check_whether_manifest_is_changed( self, build_definition: AbstractBuildDefinition, @@ -665,6 +729,9 @@ def build_single_layer_definition(self, layer_definition: LayerBuildDefinition) ) return self._cached_build_strategy.build_single_layer_definition(layer_definition) + def build_single_microvm_image_definition(self, microvm_definition: MicrovmImageBuildDefinition) -> Dict[str, str]: + return self._cached_build_strategy.build_single_microvm_image_definition(microvm_definition) + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: """ After build is complete, this method cleans up redundant folders in cached directory as well as in dependencies diff --git a/samcli/lib/providers/provider.py b/samcli/lib/providers/provider.py index f18e3025e88..ba3626fe2d5 100644 --- a/samcli/lib/providers/provider.py +++ b/samcli/lib/providers/provider.py @@ -287,10 +287,63 @@ def get_build_entry_point(function: BuildableFunction) -> Optional[str]: return function.handler +class MicrovmImage: + """Represents an AWS::Serverless::MicrovmImage resource for building.""" + + def __init__( + self, + name: str, + code_uri: str, + architecture: Optional[str], + microvm_image_name: Optional[str] = None, + stack_path: str = "", + metadata: Optional[Dict] = None, + ) -> None: + self.name = name + self.microvm_image_name = microvm_image_name + self.code_uri = code_uri + self.architecture = architecture + self.stack_path = stack_path + self.metadata = metadata or {} + + @property + def microvm_image_id(self) -> str: + """ + Unique id for MicrovmImage resource. + For SAM/CFN project, this is the logical id. + For CDK project, this is the SamResourceId from metadata, or the logical id if not found. + """ + if self.metadata: + sam_resource_id = self.metadata.get("SamResourceId") + if isinstance(sam_resource_id, str) and sam_resource_id: + return sam_resource_id + return self.name + + @property + def full_path(self) -> str: + """ + Return the path-like identifier of this MicrovmImage. If it is in root stack, full_path = microvm_image_id. + This path is guaranteed to be unique in a multi-stack situation. + Example: + "MyAgent" + "ChildStackA/MyAgent" + """ + return get_full_path(self.stack_path, self.microvm_image_id) + + def __eq__(self, other: object) -> bool: + if isinstance(other, MicrovmImage): + return self.__dict__ == other.__dict__ + return False + + def __hash__(self) -> int: + return hash(self.full_path) + + class ResourcesToBuildCollector: def __init__(self) -> None: self._functions: List[BuildableFunction] = [] self._layers: List["LayerVersion"] = [] + self._microvm_images: List[MicrovmImage] = [] def add_function(self, function: BuildableFunction) -> None: self._functions.append(function) @@ -304,6 +357,12 @@ def add_layer(self, layer: "LayerVersion") -> None: def add_layers(self, layers: List["LayerVersion"]) -> None: self._layers.extend(layers) + def add_microvm_image(self, microvm_image: MicrovmImage) -> None: + self._microvm_images.append(microvm_image) + + def add_microvm_images(self, microvm_images: List[MicrovmImage]) -> None: + self._microvm_images.extend(microvm_images) + @property def functions(self) -> List[BuildableFunction]: return self._functions @@ -312,6 +371,10 @@ def functions(self) -> List[BuildableFunction]: def layers(self) -> List["LayerVersion"]: return self._layers + @property + def microvm_images(self) -> List[MicrovmImage]: + return self._microvm_images + def __eq__(self, other: object) -> bool: if isinstance(other, type(self)): return self.__dict__ == other.__dict__ diff --git a/samcli/lib/providers/sam_base_provider.py b/samcli/lib/providers/sam_base_provider.py index 4b02e02e08e..7c7f033ad5b 100644 --- a/samcli/lib/providers/sam_base_provider.py +++ b/samcli/lib/providers/sam_base_provider.py @@ -15,8 +15,10 @@ from samcli.lib.utils.resources import ( AWS_LAMBDA_FUNCTION, AWS_LAMBDA_LAYERVERSION, + AWS_LAMBDA_MICROVMIMAGE, AWS_SERVERLESS_FUNCTION, AWS_SERVERLESS_LAYERVERSION, + AWS_SERVERLESS_MICROVMIMAGE, ) LOG = logging.getLogger(__name__) @@ -34,6 +36,8 @@ class SamBaseProvider: AWS_SERVERLESS_FUNCTION: "CodeUri", AWS_LAMBDA_LAYERVERSION: "Content", AWS_SERVERLESS_LAYERVERSION: "ContentUri", + AWS_LAMBDA_MICROVMIMAGE: "CodeArtifact", + AWS_SERVERLESS_MICROVMIMAGE: "CodeUri", } IMAGE_PROPERTY_KEYS = { diff --git a/samcli/lib/providers/sam_microvm_image_provider.py b/samcli/lib/providers/sam_microvm_image_provider.py new file mode 100644 index 00000000000..500b31cff78 --- /dev/null +++ b/samcli/lib/providers/sam_microvm_image_provider.py @@ -0,0 +1,149 @@ +"""Provider for AWS::Serverless::MicrovmImage resources.""" + +import logging +from typing import Dict, List, Optional + +from samcli.lib.providers.provider import MicrovmImage, Stack +from samcli.lib.providers.sam_base_provider import SamBaseProvider +from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider +from samcli.lib.utils.architecture import ARM64 +from samcli.lib.utils.resources import AWS_LAMBDA_MICROVMIMAGE, AWS_SERVERLESS_MICROVMIMAGE + +LOG = logging.getLogger(__name__) + +_MICROVMIMAGE_RESOURCE_TYPES = {AWS_SERVERLESS_MICROVMIMAGE, AWS_LAMBDA_MICROVMIMAGE} + +# MicrovmImage currently only supports ARM_64. +_SUPPORTED_ARCHITECTURES = { + "ARM_64": ARM64, +} +_DEFAULT_ARCHITECTURE = ARM64 + + +class SamMicrovmImageProvider(SamBaseProvider): + """Extracts MicrovmImage resources from SAM templates.""" + + def __init__( + self, + stacks: List[Stack], + use_raw_codeuri: bool = False, + ignore_code_extraction_warnings: bool = False, + ) -> None: + self._microvm_images: List[MicrovmImage] = [] + self._use_raw_codeuri = use_raw_codeuri + self._ignore_code_extraction_warnings = ignore_code_extraction_warnings + self._extract_microvm_images(stacks) + + def _extract_microvm_images(self, stacks: List[Stack]) -> None: + for stack in stacks: + for logical_id, resource in stack.resources.items(): + resource_type = resource.get("Type") + if resource_type not in _MICROVMIMAGE_RESOURCE_TYPES: + continue + + properties = resource.get("Properties", {}) + metadata = resource.get("Metadata", None) + code_property_key = SamBaseProvider.CODE_PROPERTY_KEYS[resource_type] + code_value = properties.get(code_property_key, "") + # AWS::Lambda::MicrovmImage.CodeArtifact is a {"Uri": ...} structure when hand-written; + # CDK templates arrive as a bare string after ResourceMetadataNormalizer. Accept both. + if isinstance(code_value, dict) and "Uri" in code_value: + code_value = code_value["Uri"] + + # Skip resources with S3 code — not buildable locally + if SamBaseProvider._is_s3_location(code_value): + if not self._ignore_code_extraction_warnings: + SamBaseProvider._warn_code_extraction(resource_type, logical_id, code_property_key) + continue + + # Only extract if code_value is a non-empty local path string + if isinstance(code_value, str) and code_value: + microvm_image = self._convert_microvm_image_resource( + stack, logical_id, code_value, properties, metadata + ) + self._microvm_images.append(microvm_image) + else: + LOG.warning( + "MicrovmImage resource '%s' has missing or invalid %s and will be skipped for build.", + logical_id, + code_property_key, + ) + + def _convert_microvm_image_resource( + self, + stack: Stack, + logical_id: str, + code_uri: str, + resource_properties: Dict, + metadata: Optional[Dict] = None, + ) -> MicrovmImage: + """ + Converts a MicrovmImage resource to a MicrovmImage configuration usable by the provider. + + Parameters + ---------- + stack : Stack + The stack the resource is defined in + logical_id : str + LogicalID of the resource + code_uri : str + Local path to the source directory containing the Dockerfile + resource_properties : dict + Properties of this resource + metadata : dict + Metadata of this resource + + Returns + ------- + MicrovmImage + MicrovmImage configuration + """ + if not self._use_raw_codeuri: + code_uri = SamLocalStackProvider.normalize_resource_path(stack.location, code_uri) + + # Extract architecture from CpuConfigurations[0].Architecture, mapping CFN's format + # (e.g. "ARM_64") to SAM CLI's internal format (e.g. "arm64"). Defaults to arm64 when + # CpuConfigurations is not specified, matching the only architecture supported today. + architecture: Optional[str] = _DEFAULT_ARCHITECTURE + cpu_configurations = resource_properties.get("CpuConfigurations", []) + if cpu_configurations and isinstance(cpu_configurations, list) and len(cpu_configurations) > 0: + first_config = cpu_configurations[0] + if isinstance(first_config, dict) and "Architecture" in first_config: + cfn_architecture = first_config.get("Architecture") + # An unresolvable !Ref is rewritten by IntrinsicResolver to the parameter name + # (a string), so it lands here as an unsupported value rather than a dict. + architecture = ( + _SUPPORTED_ARCHITECTURES.get(cfn_architecture) if isinstance(cfn_architecture, str) else None + ) + if architecture is None: + LOG.warning( + "MicrovmImage '%s' has unsupported Architecture %r; only ARM_64 is supported. " + "If Architecture references a template parameter, provide its value via " + "--parameter-overrides.", + logical_id, + cfn_architecture, + ) + + return MicrovmImage( + name=logical_id, + microvm_image_name=resource_properties.get("Name"), + code_uri=code_uri, + architecture=architecture, + stack_path=stack.stack_path, + metadata=metadata, + ) + + def get_all(self) -> List[MicrovmImage]: + return self._microvm_images + + def get(self, name: str) -> Optional[MicrovmImage]: + if not name: + return None + # Identity fields win over Properties.Name, so one image's Name cannot shadow another's logical ID. + for image in self._microvm_images: + if name in (image.full_path, image.microvm_image_id, image.name): + return image + for image in self._microvm_images: + if name == image.microvm_image_name: + return image + return None diff --git a/samcli/lib/utils/resources.py b/samcli/lib/utils/resources.py index 899aadbbc31..22ae8a4919e 100644 --- a/samcli/lib/utils/resources.py +++ b/samcli/lib/utils/resources.py @@ -10,9 +10,11 @@ AWS_SERVERLESS_FUNCTION = "AWS::Serverless::Function" AWS_SERVERLESS_LAYERVERSION = "AWS::Serverless::LayerVersion" AWS_SERVERLESS_WEBFUNCTION = "AWS::Serverless::WebFunction" +AWS_SERVERLESS_MICROVMIMAGE = "AWS::Serverless::MicrovmImage" AWS_LAMBDA_FUNCTION = "AWS::Lambda::Function" AWS_LAMBDA_LAYERVERSION = "AWS::Lambda::LayerVersion" +AWS_LAMBDA_MICROVMIMAGE = "AWS::Lambda::MicrovmImage" # APIGW AWS_SERVERLESS_API = "AWS::Serverless::Api" @@ -93,6 +95,9 @@ AWS_SERVERLESS_APPLICATION: ["Location"], AWS_LAMBDA_LAYERVERSION: ["Content"], AWS_SERVERLESS_LAYERVERSION: ["ContentUri"], + AWS_SERVERLESS_MICROVMIMAGE: ["CodeUri"], + # "CodeArtifact" covers the bare-string form CDK normalizes to; "CodeArtifact.Uri" the hand-written structure. + AWS_LAMBDA_MICROVMIMAGE: ["CodeArtifact", "CodeArtifact.Uri"], AWS_GLUE_JOB: ["Command.ScriptLocation"], AWS_STEPFUNCTIONS_STATEMACHINE: ["DefinitionS3Location"], } diff --git a/schema/samcli.json b/schema/samcli.json index fb48d4346fb..a4912dcdab2 100644 --- a/schema/samcli.json +++ b/schema/samcli.json @@ -306,7 +306,7 @@ }, "build": { "title": "Build command", - "description": "Build AWS serverless function code.", + "description": "Build AWS serverless function code and MicroVM images.", "properties": { "parameters": { "title": "Parameters for the build command", diff --git a/tests/integration/buildcmd/test_build_cmd_microvm_image.py b/tests/integration/buildcmd/test_build_cmd_microvm_image.py new file mode 100644 index 00000000000..81b1c964664 --- /dev/null +++ b/tests/integration/buildcmd/test_build_cmd_microvm_image.py @@ -0,0 +1,111 @@ +"""Integration tests for building MicrovmImage resources.""" + +import logging +import os +from pathlib import Path +from unittest import skipIf + +import pytest +from parameterized import parameterized_class + +from samcli.local.docker.image_build_client import CLIBuildClient +from samcli.local.docker.container_client_factory import ContainerClientFactory +from samcli.local.docker.utils import get_validated_container_client +from samcli.yamlhelper import yaml_parse +from tests.testing_utils import ( + run_command, + SKIP_DOCKER_TESTS, + SKIP_DOCKER_MESSAGE, +) +from tests.integration.buildcmd.build_integ_base import BuildIntegBase + +LOG = logging.getLogger(__name__) + + +@skipIf(SKIP_DOCKER_TESTS, SKIP_DOCKER_MESSAGE) +@parameterized_class( + ("use_buildkit",), + [ + (False,), + (True,), + ], +) +@pytest.mark.filterwarnings("ignore::ResourceWarning") +class TestBuildMicrovmImage(BuildIntegBase): + """Test building MicrovmImage resources with sam build.""" + + template = "template_microvm_image.yaml" + + def setUp(self): + super().setUp() + if self.use_buildkit: + client = ContainerClientFactory.create_client() + is_available, error_msg = CLIBuildClient.is_available(client.get_runtime_type()) + if not is_available: + self.skipTest(f"Buildkit not available: {error_msg}") + + @pytest.mark.tier1 + def test_build_microvm_image_succeeds(self): + """sam build should succeed and create a Docker image tagged :latest.""" + cmdlist = self.get_command_list(use_buildkit=self.use_buildkit) + command_result = run_command(cmdlist, cwd=self.working_dir) + + self.assertEqual(command_result.process.returncode, 0) + + # Verify the image was created with convention-based tag + expected_tag = "mymicrovmimage:latest" + container_client = get_validated_container_client() + images = container_client.images.list(name=expected_tag) + self.assertTrue(bool(images), f"Docker image '{expected_tag}' should exist") + + def test_built_template_code_uri_resolved(self): + """Built template should have intrinsic CodeUri resolved to a literal path. + + When CodeUri is an intrinsic (e.g. !Ref), update_template resolves it to the + literal value so that move_template can correctly rebase the relative path. + The Docker image itself is local-only — CodeUri is not replaced with an artifact + path like ZIP functions, but the relative path is valid from .aws-sam/build/. + """ + cmdlist = self.get_command_list(use_buildkit=self.use_buildkit) + command_result = run_command(cmdlist, cwd=self.working_dir) + + self.assertEqual(command_result.process.returncode, 0) + + # Read built template + self.assertTrue(self.built_template.exists(), "Built template should exist") + with open(str(self.built_template), "r") as f: + built_template_dict = yaml_parse(f.read()) + built_code_uri = built_template_dict["Resources"]["MyMicrovmImage"]["Properties"]["CodeUri"] + + # CodeUri should be a string (resolved from the intrinsic), not the original dict. + # The exact value depends on the relative path from .aws-sam/build/ to the source. + self.assertIsInstance(built_code_uri, str) + self.assertNotEqual(built_code_uri, "") + + def test_no_artifact_directory_created(self): + """MicrovmImage should NOT create an artifact subdirectory in .aws-sam/build/ (unlike ZIP functions).""" + cmdlist = self.get_command_list(use_buildkit=self.use_buildkit) + command_result = run_command(cmdlist, cwd=self.working_dir) + + self.assertEqual(command_result.process.returncode, 0) + + build_dir_files = os.listdir(str(self.default_build_dir)) + self.assertNotIn("MyMicrovmImage", build_dir_files) + + def test_build_fails_when_dockerfile_not_found(self): + """sam build should fail when CodeUri directory doesn't contain a Dockerfile.""" + overrides = {"CodeUri": "./NonExistentDirectory"} + cmdlist = self.get_command_list(parameter_overrides=overrides, use_buildkit=self.use_buildkit) + command_result = run_command(cmdlist, cwd=self.working_dir) + + self.assertEqual(command_result.process.returncode, 1) + self.assertIn("Dockerfile not found", command_result.stderr.decode()) + + def tearDown(self): + # Clean up the image created during the test (works across Docker and Finch) + try: + container_client = get_validated_container_client() + container_client.remove_image_safely("mymicrovmimage:latest") + except Exception: + pass + super().tearDown() diff --git a/tests/integration/testdata/buildcmd/MicrovmImage/Dockerfile b/tests/integration/testdata/buildcmd/MicrovmImage/Dockerfile new file mode 100644 index 00000000000..324736f2913 --- /dev/null +++ b/tests/integration/testdata/buildcmd/MicrovmImage/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY app.py . + +EXPOSE 8080 + +CMD ["python", "app.py"] diff --git a/tests/integration/testdata/buildcmd/MicrovmImage/app.py b/tests/integration/testdata/buildcmd/MicrovmImage/app.py new file mode 100644 index 00000000000..ad5c9e3e816 --- /dev/null +++ b/tests/integration/testdata/buildcmd/MicrovmImage/app.py @@ -0,0 +1,17 @@ +"""Simple HTTP server for MicrovmImage integration test.""" + +from http.server import HTTPServer, BaseHTTPRequestHandler +import json + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"status": "ok"}).encode()) + + +if __name__ == "__main__": + server = HTTPServer(("0.0.0.0", 8080), Handler) + server.serve_forever() diff --git a/tests/integration/testdata/buildcmd/template_microvm_image.yaml b/tests/integration/testdata/buildcmd/template_microvm_image.yaml new file mode 100644 index 00000000000..3222b4d6c0c --- /dev/null +++ b/tests/integration/testdata/buildcmd/template_microvm_image.yaml @@ -0,0 +1,16 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 + +Parameters: + CodeUri: + Type: String + Default: ./MicrovmImage + +Resources: + MyMicrovmImage: + Type: AWS::Serverless::MicrovmImage + Properties: + Name: my-microvm-app + CodeUri: !Ref CodeUri + BaseImageArn: arn:aws:lambda:us-east-2:aws:microvm-image:al2023-1 + BaseImageVersion: "0" diff --git a/tests/unit/commands/buildcmd/test_build_context.py b/tests/unit/commands/buildcmd/test_build_context.py index 29cc503923a..d521fb0df1d 100644 --- a/tests/unit/commands/buildcmd/test_build_context.py +++ b/tests/unit/commands/buildcmd/test_build_context.py @@ -112,6 +112,7 @@ class TestBuildContext__enter__(TestCase): @patch("samcli.commands.build.build_context.get_template_data") @patch("samcli.commands.build.build_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.build.build_context.SamFunctionProvider") + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") @patch("samcli.commands.build.build_context.SamLayerProvider") @patch("samcli.commands.build.build_context.pathlib") @patch("samcli.commands.build.build_context.ContainerManager") @@ -120,10 +121,13 @@ def test_must_setup_context( ContainerManagerMock, pathlib_mock, SamLayerProviderMock, + SamMicrovmImageProviderMock, SamFunctionProviderMock, get_buildable_stacks_mock, get_template_data_mock, ): + SamMicrovmImageProviderMock.return_value.get.return_value = None + SamMicrovmImageProviderMock.return_value.get_all.return_value = [] template_dict = "template dict" stack = Mock() stack.template_dict = template_dict @@ -196,6 +200,7 @@ def test_must_setup_context( @patch("samcli.commands.build.build_context.get_template_data") @patch("samcli.commands.build.build_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.build.build_context.SamFunctionProvider") + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") @patch("samcli.commands.build.build_context.SamLayerProvider") @patch("samcli.commands.build.build_context.pathlib") @patch("samcli.commands.build.build_context.ContainerManager") @@ -204,10 +209,13 @@ def test_must_fail_with_illegal_identifier( ContainerManagerMock, pathlib_mock, SamLayerProviderMock, + SamMicrovmImageProviderMock, SamFunctionProviderMock, get_buildable_stacks_mock, get_template_data_mock, ): + SamMicrovmImageProviderMock.return_value.get.return_value = None + SamMicrovmImageProviderMock.return_value.get_all.return_value = [] template_dict = "template dict" stack = Mock() stack.template_dict = template_dict @@ -253,6 +261,7 @@ def test_must_fail_with_illegal_identifier( @patch("samcli.commands.build.build_context.get_template_data") @patch("samcli.commands.build.build_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.build.build_context.SamFunctionProvider") + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") @patch("samcli.commands.build.build_context.SamLayerProvider") @patch("samcli.commands.build.build_context.pathlib") @patch("samcli.commands.build.build_context.ContainerManager") @@ -261,10 +270,13 @@ def test_must_return_only_layer_when_layer_is_build( ContainerManagerMock, pathlib_mock, SamLayerProviderMock, + SamMicrovmImageProviderMock, SamFunctionProviderMock, get_buildable_stacks_mock, get_template_data_mock, ): + SamMicrovmImageProviderMock.return_value.get.return_value = None + SamMicrovmImageProviderMock.return_value.get_all.return_value = [] template_dict = "template dict" stack = Mock() stack.template_dict = template_dict @@ -308,6 +320,7 @@ def test_must_return_only_layer_when_layer_is_build( @patch("samcli.commands.build.build_context.get_template_data") @patch("samcli.commands.build.build_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.build.build_context.SamFunctionProvider") + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") @patch("samcli.commands.build.build_context.SamLayerProvider") @patch("samcli.commands.build.build_context.pathlib") @patch("samcli.commands.build.build_context.ContainerManager") @@ -316,10 +329,13 @@ def test_must_return_buildable_dependent_layer_when_function_is_build( ContainerManagerMock, pathlib_mock, SamLayerProviderMock, + SamMicrovmImageProviderMock, SamFunctionProviderMock, get_buildable_stacks_mock, get_template_data_mock, ): + SamMicrovmImageProviderMock.return_value.get.return_value = None + SamMicrovmImageProviderMock.return_value.get_all.return_value = [] template_dict = "template dict" stack = Mock() stack.template_dict = template_dict @@ -369,6 +385,7 @@ def test_must_return_buildable_dependent_layer_when_function_is_build( @patch("samcli.commands.build.build_context.get_template_data") @patch("samcli.commands.build.build_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.build.build_context.SamFunctionProvider") + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") @patch("samcli.commands.build.build_context.SamLayerProvider") @patch("samcli.commands.build.build_context.pathlib") @patch("samcli.commands.build.build_context.ContainerManager") @@ -377,10 +394,13 @@ def test_must_fail_when_layer_is_build_without_buildmethod( ContainerManagerMock, pathlib_mock, SamLayerProviderMock, + SamMicrovmImageProviderMock, SamFunctionProviderMock, get_buildable_stacks_mock, get_template_data_mock, ): + SamMicrovmImageProviderMock.return_value.get.return_value = None + SamMicrovmImageProviderMock.return_value.get_all.return_value = [] template_dict = "template dict" stack = Mock() stack.template_dict = template_dict @@ -425,6 +445,7 @@ def test_must_fail_when_layer_is_build_without_buildmethod( @patch("samcli.commands.build.build_context.get_template_data") @patch("samcli.commands.build.build_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.build.build_context.SamFunctionProvider") + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") @patch("samcli.commands.build.build_context.SamLayerProvider") @patch("samcli.commands.build.build_context.pathlib") @patch("samcli.commands.build.build_context.ContainerManager") @@ -433,10 +454,13 @@ def test_must_return_many_functions_to_build( ContainerManagerMock, pathlib_mock, SamLayerProviderMock, + SamMicrovmImageProviderMock, SamFunctionProviderMock, get_buildable_stacks_mock, get_template_data_mock, ): + SamMicrovmImageProviderMock.return_value.get.return_value = None + SamMicrovmImageProviderMock.return_value.get_all.return_value = [] """ In this unit test, we also verify - inlinecode functions are skipped @@ -547,6 +571,7 @@ def test_must_return_many_functions_to_build( @patch("samcli.commands.build.build_context.get_template_data") @patch("samcli.commands.build.build_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.build.build_context.SamFunctionProvider") + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") @patch("samcli.commands.build.build_context.SamLayerProvider") @patch("samcli.commands.build.build_context.pathlib") @patch("samcli.commands.build.build_context.ContainerManager") @@ -558,10 +583,13 @@ def test_must_exclude_functions_from_build( ContainerManagerMock, pathlib_mock, SamLayerProviderMock, + SamMicrovmImageProviderMock, SamFunctionProviderMock, get_buildable_stacks_mock, get_template_data_mock, ): + SamMicrovmImageProviderMock.return_value.get.return_value = None + SamMicrovmImageProviderMock.return_value.get_all.return_value = [] template_dict = "template dict" stack = Mock() stack.template_dict = template_dict @@ -631,6 +659,7 @@ def test_must_exclude_functions_from_build( @patch("samcli.commands.build.build_context.get_template_data") @patch("samcli.commands.build.build_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.build.build_context.SamFunctionProvider") + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") @patch("samcli.commands.build.build_context.SamLayerProvider") @patch("samcli.commands.build.build_context.pathlib") @patch("samcli.commands.build.build_context.ContainerManager") @@ -641,11 +670,14 @@ def test_must_print_remote_url_warning( ContainerManagerMock, pathlib_mock, SamLayerProviderMock, + SamMicrovmImageProviderMock, SamFunctionProviderMock, get_buildable_stacks_mock, get_template_data_mock, log_mock, ): + SamMicrovmImageProviderMock.return_value.get.return_value = None + SamMicrovmImageProviderMock.return_value.get_all.return_value = [] get_buildable_stacks_mock.return_value = ([], remote_stack_full_paths) context = BuildContext( @@ -678,6 +710,7 @@ def test_must_print_remote_url_warning( @patch("samcli.commands.build.build_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.build.build_context.SamApiProvider") @patch("samcli.commands.build.build_context.SamFunctionProvider") + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") @patch("samcli.commands.build.build_context.SamLayerProvider") @patch("samcli.commands.build.build_context.pathlib") @patch("samcli.commands.build.build_context.ContainerManager") @@ -700,10 +733,13 @@ def test_run_sync_build_context( ContainerManagerMock, pathlib_mock, SamLayerProviderMock, + SamMicrovmImageProviderMock, SamFunctionProviderMock, SamApiProviderMock, get_buildable_stacks_mock, ): + SamMicrovmImageProviderMock.return_value.get.return_value = None + SamMicrovmImageProviderMock.return_value.get_all.return_value = [] root_stack = Mock() root_stack.is_root_stack = True auto_dependency_layer = False @@ -1012,11 +1048,20 @@ class TestBuildContext_setup_cached_and_deps_dir(TestCase): @patch("samcli.commands.build.build_context.pathlib.Path") @patch("samcli.commands.build.build_context.SamLocalStackProvider") @patch("samcli.commands.build.build_context.SamFunctionProvider") + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") @patch("samcli.commands.build.build_context.SamLayerProvider") def test_cached_dir_and_deps_dir_creation( - self, cached, patched_layer, patched_function, patched_stack, patched_path + self, + cached, + patched_layer, + patched_microvm, + patched_function, + patched_stack, + patched_path, ): patched_stack.get_stacks.return_value = ([], None) + patched_microvm.return_value.get.return_value = None + patched_microvm.return_value.get_all.return_value = [] build_context = BuildContext( resource_identifier="function_identifier", template_file="template_file", @@ -1056,6 +1101,7 @@ class TestBuildContext_run(TestCase): @patch("samcli.commands.build.build_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.build.build_context.SamApiProvider") @patch("samcli.commands.build.build_context.SamFunctionProvider") + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") @patch("samcli.commands.build.build_context.SamLayerProvider") @patch("samcli.commands.build.build_context.pathlib") @patch("samcli.commands.build.build_context.ContainerManager") @@ -1083,11 +1129,14 @@ def test_run_build_context( ContainerManagerMock, pathlib_mock, SamLayerProviderMock, + SamMicrovmImageProviderMock, SamFunctionProviderMock, SamApiProviderMock, get_buildable_stacks_mock, nested_stack_manager_mock, ): + SamMicrovmImageProviderMock.return_value.get.return_value = None + SamMicrovmImageProviderMock.return_value.get_all.return_value = [] root_stack = Mock() root_stack.is_root_stack = True root_stack.get_output_template_path = Mock(return_value="./build_dir/template.yaml") @@ -1249,7 +1298,9 @@ def test_run_json_mode_emits_single_parseable_document( 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=[]) + resources_mock.return_value = Mock( + functions=[get_function("Fn", runtime="python3.12")], layers=[], microvm_images=[] + ) 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" @@ -1300,6 +1351,7 @@ def test_run_json_mode_emits_single_parseable_document( @patch("samcli.commands.build.build_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.build.build_context.SamApiProvider") @patch("samcli.commands.build.build_context.SamFunctionProvider") + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") @patch("samcli.commands.build.build_context.SamLayerProvider") @patch("samcli.commands.build.build_context.pathlib") @patch("samcli.commands.build.build_context.ContainerManager") @@ -1325,10 +1377,13 @@ def test_must_catch_known_exceptions( ContainerManagerMock, pathlib_mock, SamLayerProviderMock, + SamMicrovmImageProviderMock, SamFunctionProviderMock, SamApiProviderMock, get_buildable_stacks_mock, ): + SamMicrovmImageProviderMock.return_value.get.return_value = None + SamMicrovmImageProviderMock.return_value.get_all.return_value = [] stack = Mock() resources_mock.return_value = Mock(functions=[], layers=[]) @@ -1385,6 +1440,7 @@ def test_must_catch_known_exceptions( @patch("samcli.commands.build.build_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.build.build_context.SamApiProvider") @patch("samcli.commands.build.build_context.SamFunctionProvider") + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") @patch("samcli.commands.build.build_context.SamLayerProvider") @patch("samcli.commands.build.build_context.pathlib") @patch("samcli.commands.build.build_context.ContainerManager") @@ -1407,10 +1463,13 @@ def test_must_catch_function_not_found_exception( ContainerManagerMock, pathlib_mock, SamLayerProviderMock, + SamMicrovmImageProviderMock, SamFunctionProviderMock, SamApiProviderMock, get_buildable_stacks_mock, ): + SamMicrovmImageProviderMock.return_value.get.return_value = None + SamMicrovmImageProviderMock.return_value.get_all.return_value = [] stack = Mock() resources_mock.return_value = Mock() @@ -2865,3 +2924,139 @@ def test_none_with_env_truthy(self): with patch.dict(os.environ, {"SAM_CLI_ENABLE_LANGUAGE_EXTENSIONS": "true"}, clear=False): ctx = self._ctx(language_extensions=None) assert ctx.language_extensions_enabled is True + + +class TestMicrovmImageBuildability(TestCase): + """Tests for is_microvm_image_buildable and MicrovmImage collection logic.""" + + def test_is_buildable_with_valid_image(self): + from samcli.lib.providers.provider import MicrovmImage + + image = MicrovmImage(name="MyImage", code_uri="./app", architecture="arm64") + buildable, reason = BuildContext.is_microvm_image_buildable(image) + self.assertTrue(buildable) + self.assertEqual(reason, "") + + def test_is_not_buildable_zip_codeuri(self): + from samcli.lib.providers.provider import MicrovmImage + + image = MicrovmImage(name="MyImage", code_uri="app.zip", architecture="arm64") + buildable, reason = BuildContext.is_microvm_image_buildable(image) + self.assertFalse(buildable) + self.assertIn(".zip", reason) + + def test_is_not_buildable_skip_build(self): + from samcli.lib.providers.provider import MicrovmImage + + image = MicrovmImage(name="MyImage", code_uri="./app", architecture="arm64", metadata={"SkipBuild": True}) + buildable, reason = BuildContext.is_microvm_image_buildable(image) + self.assertFalse(buildable) + self.assertIn("SkipBuild", reason) + + def test_is_not_buildable_none_architecture(self): + from samcli.lib.providers.provider import MicrovmImage + + image = MicrovmImage(name="MyImage", code_uri="./app", architecture=None) + buildable, reason = BuildContext.is_microvm_image_buildable(image) + self.assertFalse(buildable) + self.assertIn("architecture", reason) + + +class TestCollectSingleMicrovmImage(TestCase): + """Tests for _collect_single_microvm_image.""" + + def _make_context(self): + ctx = BuildContext( + resource_identifier="MyImage", + template_file="template.yaml", + base_dir=None, + build_dir="build", + cache_dir=".cache", + cached=False, + parallel=False, + mode=None, + ) + return ctx + + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") + def test_collect_adds_image_unconditionally(self, mock_provider_cls): + from samcli.lib.providers.provider import MicrovmImage + + image = MicrovmImage(name="MyImage", code_uri="./app", architecture="arm64", metadata={"SkipBuild": True}) + ctx = self._make_context() + mock_provider = Mock() + mock_provider.get.return_value = image + ctx._microvm_image_provider = mock_provider + + collector = ResourcesToBuildCollector() + ctx._collect_single_microvm_image("MyImage", collector) + + self.assertEqual(len(collector.microvm_images), 1) + self.assertEqual(collector.microvm_images[0], image) + + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") + def test_collect_raises_build_error_for_none_architecture(self, mock_provider_cls): + from samcli.lib.providers.provider import MicrovmImage + + image = MicrovmImage(name="MyImage", code_uri="./app", architecture=None) + ctx = self._make_context() + mock_provider = Mock() + mock_provider.get.return_value = image + ctx._microvm_image_provider = mock_provider + + collector = ResourcesToBuildCollector() + with self.assertRaises(BuildError) as cm: + ctx._collect_single_microvm_image("MyImage", collector) + self.assertIn("unsupported architecture", str(cm.exception)) + + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") + def test_collect_returns_when_image_not_found(self, mock_provider_cls): + ctx = self._make_context() + mock_provider = Mock() + mock_provider.get.return_value = None + ctx._microvm_image_provider = mock_provider + + collector = ResourcesToBuildCollector() + ctx._collect_single_microvm_image("NonExistent", collector) + + self.assertEqual(len(collector.microvm_images), 0) + + +class TestCollectAllMicrovmImages(TestCase): + """Tests for collect_all_build_resources MicrovmImage filtering.""" + + @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") + @patch("samcli.commands.build.build_context.SamLayerProvider") + @patch("samcli.commands.build.build_context.SamFunctionProvider") + def test_collect_all_skips_unbuildable_with_warning(self, mock_func_prov, mock_layer_prov, mock_mvm_prov): + from samcli.lib.providers.provider import MicrovmImage + + buildable_image = MicrovmImage(name="Good", code_uri="./app", architecture="arm64") + unbuildable_image = MicrovmImage(name="Bad", code_uri="./app", architecture=None) + + ctx = BuildContext( + resource_identifier=None, + template_file="template.yaml", + base_dir=None, + build_dir="build", + cache_dir=".cache", + cached=False, + parallel=False, + mode=None, + ) + mock_mvm = Mock() + mock_mvm.get_all.return_value = [buildable_image, unbuildable_image] + ctx._microvm_image_provider = mock_mvm + + mock_func = Mock() + mock_func.get_all.return_value = [] + ctx._function_provider = mock_func + + mock_layer = Mock() + mock_layer.get_all.return_value = [] + ctx._layer_provider = mock_layer + + result = ctx.collect_all_build_resources() + + self.assertEqual(len(result.microvm_images), 1) + self.assertEqual(result.microvm_images[0].name, "Good") diff --git a/tests/unit/commands/buildcmd/test_build_context_web_functions.py b/tests/unit/commands/buildcmd/test_build_context_web_functions.py index 8026af197a3..dc1307125e6 100644 --- a/tests/unit/commands/buildcmd/test_build_context_web_functions.py +++ b/tests/unit/commands/buildcmd/test_build_context_web_functions.py @@ -43,6 +43,9 @@ def _context(self, web_functions, excluded=None): context._web_function_provider.get.side_effect = lambda name: next( (f for f in web_functions if f.name == name), None ) + context._microvm_image_provider = MagicMock() + context._microvm_image_provider.get_all.return_value = [] + context._microvm_image_provider.get.return_value = None return context def test_collects_buildable_web_functions(self): diff --git a/tests/unit/commands/local/lib/test_provider.py b/tests/unit/commands/local/lib/test_provider.py index 0e48c1d9e91..8be17694630 100644 --- a/tests/unit/commands/local/lib/test_provider.py +++ b/tests/unit/commands/local/lib/test_provider.py @@ -9,6 +9,7 @@ from samcli.lib.providers.provider import ( LayerVersion, + MicrovmImage, ResourceIdentifier, Stack, _get_build_dir, @@ -900,3 +901,43 @@ def test_get_child_stacks(self): self.child_child_stack, [self.root_stack, self.child_stack, self.child_child_stack] ) self.assertEqual(stack_list, []) + + +class TestMicrovmImage(TestCase): + """Tests for MicrovmImage class.""" + + @parameterized.expand( + [ + ("MyImage", "./app", "", None, "MyImage"), # root stack, full_path = name (logical_id) + ("MyImage", "./app", "ChildStack", None, "ChildStack/MyImage"), # nested stack + ("MyImage", "./app", "", {"SamResourceId": "MyImage-x"}, "MyImage-x"), # CDK with SamResourceId + ] + ) + def test_full_path(self, name, code_uri, stack_path, metadata, expected_full_path): + img = MicrovmImage(name=name, code_uri=code_uri, architecture="arm64", stack_path=stack_path, metadata=metadata) + self.assertEqual(img.full_path, expected_full_path) + + def test_microvm_image_name(self): + img = MicrovmImage(name="MyImage", code_uri="./app", architecture="arm64", microvm_image_name="my-app") + self.assertEqual(img.microvm_image_name, "my-app") + + @parameterized.expand( + [ + (None, {}), # None metadata defaults to empty dict + ({}, {}), # empty dict stays empty + ({"SkipBuild": True}, {"SkipBuild": True}), # metadata preserved + ] + ) + def test_metadata(self, input_metadata, expected_metadata): + img = MicrovmImage(name="MyImage", code_uri="./app", architecture="arm64", metadata=input_metadata) + self.assertEqual(img.metadata, expected_metadata) + + def test_microvm_image_id_defaults_to_name(self): + img = MicrovmImage(name="MyImage", code_uri="./app", architecture="arm64") + self.assertEqual(img.microvm_image_id, "MyImage") + + def test_microvm_image_id_uses_sam_resource_id(self): + img = MicrovmImage( + name="MyImage", code_uri="./app", architecture="arm64", metadata={"SamResourceId": "MyImage-x"} + ) + self.assertEqual(img.microvm_image_id, "MyImage-x") diff --git a/tests/unit/commands/local/lib/test_sam_microvm_image_provider.py b/tests/unit/commands/local/lib/test_sam_microvm_image_provider.py new file mode 100644 index 00000000000..0c4b0037097 --- /dev/null +++ b/tests/unit/commands/local/lib/test_sam_microvm_image_provider.py @@ -0,0 +1,266 @@ +"""Unit tests for SamMicrovmImageProvider.""" + +import os +import posixpath +from unittest import TestCase +from unittest.mock import patch + +from parameterized import parameterized + +from samcli.lib.providers.provider import MicrovmImage, Stack +from samcli.lib.providers.sam_microvm_image_provider import SamMicrovmImageProvider + + +class TestSamMicrovmImageProvider(TestCase): + TEMPLATE = { + "Resources": { + "ServerlessMicrovmImage": { + "Type": "AWS::Serverless::MicrovmImage", + "Properties": { + "Name": "my-app", + "CodeUri": "./app", + "BaseImageArn": "arn:aws:lambda:::microvm-image:base", + "BaseImageVersion": "0", + }, + }, + "ServerlessMicrovmImageWithMetadata": { + "Type": "AWS::Serverless::MicrovmImage", + "Properties": { + "Name": "with-metadata", + "CodeUri": "./app3", + "BaseImageArn": "arn:aws:lambda:::microvm-image:base", + "BaseImageVersion": "0", + }, + "Metadata": {"SkipBuild": True}, + }, + "ServerlessMicrovmImageS3": { + "Type": "AWS::Serverless::MicrovmImage", + "Properties": { + "Name": "s3-app", + "CodeUri": "s3://bucket/app.zip", + "BaseImageArn": "arn:aws:lambda:::microvm-image:base", + "BaseImageVersion": "0", + }, + }, + "LambdaMicrovmImage": { + "Type": "AWS::Lambda::MicrovmImage", + "Properties": { + "CodeArtifact": "./lambda-app", + }, + }, + "MicrovmImageWithArch": { + "Type": "AWS::Serverless::MicrovmImage", + "Properties": { + "Name": "arch-app", + "CodeUri": "./app-arch", + "BaseImageArn": "arn:aws:lambda:::microvm-image:base", + "BaseImageVersion": "0", + "CpuConfigurations": [{"Architecture": "ARM_64"}], + }, + }, + "SamFunc": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/key", + "Runtime": "python3.12", + "Handler": "index.handler", + }, + }, + "ChildStack": { + "Type": "AWS::Serverless::Application", + "Properties": { + "Location": "./child.yaml", + }, + }, + } + } + + CHILD_TEMPLATE = { + "Resources": { + "MicrovmImageInChild": { + "Type": "AWS::Serverless::MicrovmImage", + "Properties": { + "Name": "child-app", + "CodeUri": "./child-app", + "BaseImageArn": "arn:aws:lambda:::microvm-image:base", + "BaseImageVersion": "0", + }, + }, + } + } + + def setUp(self): + root_stack = Stack("", "", "template.yaml", None, self.TEMPLATE) + child_stack = Stack("", "ChildStack", "./child/template.yaml", None, self.CHILD_TEMPLATE) + self.provider = SamMicrovmImageProvider([root_stack, child_stack], use_raw_codeuri=True) + + @parameterized.expand( + [ + # (lookup name, expected name/logical_id or None) + ("ServerlessMicrovmImage", "ServerlessMicrovmImage"), # by logical ID (name field) + ("my-app", "ServerlessMicrovmImage"), # by microvm_image_name (Properties.Name) + ("with-metadata", "ServerlessMicrovmImageWithMetadata"), # by microvm_image_name + ("LambdaMicrovmImage", "LambdaMicrovmImage"), # Lambda type by logical ID + (posixpath.join("ChildStack", "MicrovmImageInChild"), "MicrovmImageInChild"), # by full_path + ("child-app", "MicrovmImageInChild"), # child stack by microvm_image_name + ("ServerlessMicrovmImageS3", None), # S3 CodeUri skipped + ("SamFunc", None), # non-microvm resource + ("NonExistent", None), # not found + ] + ) + def test_get(self, lookup_name, expected_name): + actual = self.provider.get(lookup_name) + if expected_name is None: + self.assertIsNone(actual) + else: + self.assertIsNotNone(actual) + self.assertEqual(actual.name, expected_name) + + def test_get_all_must_return_all_buildable_images(self): + result = [img.full_path for img in self.provider.get_all()] + expected = [ + "ServerlessMicrovmImage", + "ServerlessMicrovmImageWithMetadata", + "LambdaMicrovmImage", + "MicrovmImageWithArch", + posixpath.join("ChildStack", "MicrovmImageInChild"), + ] + self.assertEqual(expected, result) + + def test_s3_code_uri_is_excluded(self): + """S3 CodeUri images should not appear in get_all results.""" + all_names = [img.name for img in self.provider.get_all()] + self.assertNotIn("ServerlessMicrovmImageS3", all_names) + + def test_non_microvm_resources_are_excluded(self): + """Non-MicrovmImage resources should not appear in get_all results.""" + all_names = [img.name for img in self.provider.get_all()] + self.assertNotIn("SamFunc", all_names) + self.assertNotIn("ChildStack", all_names) + + def test_metadata_is_extracted(self): + image = self.provider.get("ServerlessMicrovmImageWithMetadata") + self.assertEqual(image.metadata.get("SkipBuild"), True) + + def test_get_returns_none_for_empty_string(self): + self.assertIsNone(self.provider.get("")) + + def test_child_stack_image_has_correct_stack_path(self): + image = self.provider.get("child-app") + self.assertEqual(image.stack_path, "ChildStack") + self.assertEqual(image.full_path, posixpath.join("ChildStack", "MicrovmImageInChild")) + + def test_architecture_extracted_from_cpu_configurations(self): + image = self.provider.get("MicrovmImageWithArch") + self.assertEqual(image.architecture, "arm64") + + def test_architecture_defaults_to_arm64_when_not_specified(self): + image = self.provider.get("ServerlessMicrovmImage") + self.assertEqual(image.architecture, "arm64") + + @parameterized.expand( + [ + # (CodeArtifact value, expected code_uri or None if skipped) + ("bare string from CDK normalize", "./lambda-app", "./lambda-app"), + ("nested local Uri", {"Uri": "./lambda-app"}, "./lambda-app"), + ("nested S3 Uri", {"Uri": "s3://bucket/app.zip"}, None), + ] + ) + def test_lambda_microvm_image_code_artifact_forms(self, _name, code_artifact, expected_code_uri): + stack = Stack( + "", + "", + "template.yaml", + None, + { + "Resources": { + "Img": {"Type": "AWS::Lambda::MicrovmImage", "Properties": {"CodeArtifact": code_artifact}} + } + }, + ) + image = SamMicrovmImageProvider([stack], use_raw_codeuri=True).get("Img") + if expected_code_uri is None: + self.assertIsNone(image) + else: + self.assertEqual(image.code_uri, expected_code_uri) + + def test_get_prefers_logical_id_over_another_images_name(self): + """One image's Properties.Name must not shadow another image's logical ID.""" + stack = Stack( + "", + "", + "template.yaml", + None, + { + "Resources": { + "ImageA": { + "Type": "AWS::Serverless::MicrovmImage", + "Properties": { + "Name": "ImageB", + "CodeUri": "./a", + "BaseImageArn": "arn:aws:lambda:::microvm-image:base", + "BaseImageVersion": "0", + }, + }, + "ImageB": { + "Type": "AWS::Serverless::MicrovmImage", + "Properties": { + "Name": "SomethingElse", + "CodeUri": "./b", + "BaseImageArn": "arn:aws:lambda:::microvm-image:base", + "BaseImageVersion": "0", + }, + }, + } + }, + ) + provider = SamMicrovmImageProvider([stack], use_raw_codeuri=True) + self.assertEqual(provider.get("ImageB").code_uri, "./b") + self.assertEqual(provider.get("SomethingElse").name, "ImageB") + + def test_ignore_code_extraction_warnings_skips_warning(self): + stack = Stack( + "", + "", + "template.yaml", + None, + { + "Resources": { + "MyImage": { + "Type": "AWS::Serverless::MicrovmImage", + "Properties": { + "Name": "my-image", + "CodeUri": "s3://bucket/app.zip", + "BaseImageArn": "arn:aws:lambda:::microvm-image:base", + "BaseImageVersion": "0", + }, + } + } + }, + ) + provider = SamMicrovmImageProvider([stack], ignore_code_extraction_warnings=True) + self.assertEqual(provider.get_all(), []) + + def test_code_uri_normalized_relative_to_template_location(self): + stack = Stack( + "", + "", + os.path.join("some", "dir", "template.yaml"), + None, + { + "Resources": { + "MyImage": { + "Type": "AWS::Serverless::MicrovmImage", + "Properties": { + "Name": "my-image", + "CodeUri": "./app", + "BaseImageArn": "arn:aws:lambda:::microvm-image:base", + "BaseImageVersion": "0", + }, + } + } + }, + ) + provider = SamMicrovmImageProvider([stack], use_raw_codeuri=False) + image = provider.get("MyImage") + self.assertEqual(image.code_uri, os.path.join("some", "dir", "app")) diff --git a/tests/unit/lib/build_module/test_app_builder.py b/tests/unit/lib/build_module/test_app_builder.py index 40e9930d23c..bbe6cea5fc5 100644 --- a/tests/unit/lib/build_module/test_app_builder.py +++ b/tests/unit/lib/build_module/test_app_builder.py @@ -24,7 +24,7 @@ ) from samcli.lib.build.exceptions import DockerConnectionError from samcli.lib.build.workflow_config import UnsupportedRuntimeException -from samcli.lib.providers.provider import Function, FunctionBuildInfo, ResourcesToBuildCollector +from samcli.lib.providers.provider import Function, FunctionBuildInfo, MicrovmImage, ResourcesToBuildCollector from samcli.lib.telemetry.event import EventTracker from samcli.lib.utils.architecture import ARM64, X86_64 from samcli.lib.utils.packagetype import IMAGE, ZIP @@ -3800,3 +3800,90 @@ def test_metadata_with_working_dir_child_source_dir(self, os_path_mock, pathlib_ working_dir = ApplicationBuilder._get_working_directory_path("base_dir", metadata, "source_dir", "scratch_dir") self.assertEqual(working_dir, PathValidator(str(os.path.join("source_dir", "working", "dir")))) + + +class TestApplicationBuilder_build_microvm_image(TestCase): + def _make_builder(self, microvm_images=None, base_dir="/project"): + resources = ResourcesToBuildCollector() + if microvm_images: + resources.add_microvm_images(microvm_images) + + builder = ApplicationBuilder.__new__(ApplicationBuilder) + builder._resources_to_build = resources + builder._base_dir = base_dir + builder._image_build_client = MagicMock() + builder._image_build_client.build_image.return_value = [] + builder._validated_container_client = MagicMock() + builder._stream_writer = MagicMock() + return builder + + @patch("samcli.lib.build.app_builder.pathlib.Path.exists", return_value=True) + def test_build_microvm_image_calls_docker_build(self, mock_exists): + image = MicrovmImage(name="MyMicroVM", code_uri="./app", architecture="arm64", microvm_image_name="MyMicroVM") + builder = self._make_builder(microvm_images=[image]) + + tag = builder._build_microvm_image("MyMicroVM", "./app", "arm64") + + self.assertEqual(tag, "mymicrovm:latest") + builder._image_build_client.build_image.assert_called_once() + call_kwargs = builder._image_build_client.build_image.call_args[1] + self.assertEqual(call_kwargs["tag"], "mymicrovm:latest") + self.assertEqual(call_kwargs["dockerfile"], "Dockerfile") + self.assertEqual(call_kwargs["platform"], "linux/arm64") + + @patch("samcli.lib.build.app_builder.pathlib.Path.exists", return_value=True) + def test_build_microvm_image_calls_docker_build_nested_stack(self, mock_exists): + # Two MicrovmImage resources sharing the same logical ID but in different nested + # stacks must produce different Docker tags. + image = MicrovmImage( + name="MyMicroVM", + code_uri="./app", + architecture="arm64", + microvm_image_name="MyMicroVM", + stack_path="ChildStackA", + ) + builder = self._make_builder(microvm_images=[image]) + + tag = builder._build_microvm_image(image.full_path, "./app", "arm64") + + self.assertEqual(image.full_path, "ChildStackA/MyMicroVM") + self.assertEqual(tag, "childstacka-mymicrovm:latest") + call_kwargs = builder._image_build_client.build_image.call_args[1] + self.assertEqual(call_kwargs["tag"], "childstacka-mymicrovm:latest") + + @patch("samcli.lib.build.app_builder.pathlib.Path.exists", return_value=False) + def test_build_microvm_image_raises_when_no_dockerfile(self, mock_exists): + image = MicrovmImage(name="MyMicroVM", code_uri="./app", architecture="arm64", microvm_image_name="MyMicroVM") + builder = self._make_builder(microvm_images=[image]) + + with self.assertRaisesRegex(DockerBuildFailed, "Dockerfile not found"): + builder._build_microvm_image("MyMicroVM", "./app", "arm64") + + @patch("samcli.lib.build.app_builder.pathlib.Path.exists", return_value=True) + def test_tag_is_lowercase(self, mock_exists): + builder = self._make_builder() + tag = builder._build_microvm_image("MyMicroVMApp", "./app", "arm64") + self.assertEqual(tag, "mymicrovmapp:latest") + + +class TestApplicationBuilder_update_built_resource_skips_microvm(TestCase): + def test_skip_serverless_microvm_image(self): + properties = {"CodeUri": "./app"} + ApplicationBuilder._update_built_resource( + "mymicrovm:latest", properties, "AWS::Serverless::MicrovmImage", "/abs/path" + ) + self.assertEqual(properties, {"CodeUri": "./app"}) + + def test_skip_lambda_microvm_image(self): + properties = {"CodeArtifact": {"Uri": "s3://bucket/app.zip"}} + ApplicationBuilder._update_built_resource( + "mymicrovm:latest", properties, "AWS::Lambda::MicrovmImage", "/abs/path" + ) + self.assertEqual(properties, {"CodeArtifact": {"Uri": "s3://bucket/app.zip"}}) + + def test_still_updates_zip_function(self): + properties = {"CodeUri": "./src", "PackageType": "Zip"} + ApplicationBuilder._update_built_resource( + ".aws-sam/build/MyFunc", properties, "AWS::Serverless::Function", ".aws-sam/build/MyFunc" + ) + self.assertEqual(properties["CodeUri"], ".aws-sam/build/MyFunc") diff --git a/tests/unit/lib/build_module/test_build_graph.py b/tests/unit/lib/build_module/test_build_graph.py index 8cd630012f1..a9948a5dbd4 100644 --- a/tests/unit/lib/build_module/test_build_graph.py +++ b/tests/unit/lib/build_module/test_build_graph.py @@ -12,6 +12,7 @@ from samcli.lib.build.build_graph import ( FunctionBuildDefinition, + MicrovmImageBuildDefinition, _function_build_definition_to_toml_table, _layer_build_definition_to_toml_table, CODE_URI_FIELD, @@ -35,7 +36,7 @@ BuildHashingInformation, HANDLER_FIELD, ) -from samcli.lib.providers.provider import Function, LayerVersion, FunctionBuildInfo +from samcli.lib.providers.provider import Function, LayerVersion, MicrovmImage, FunctionBuildInfo from samcli.lib.utils import osutils from samcli.lib.utils.packagetype import ZIP @@ -1053,3 +1054,52 @@ def test_go_runtime_different_handlers_are_not_equal(self): self.assertEqual(len(build_definitions), 2) self.assertEqual(len(build_definition1.functions), 1) self.assertEqual(len(build_definition2.functions), 1) + + +class TestMicrovmImageBuildDefinition(TestCase): + def test_eq_same_full_path_and_code_uri(self): + d1 = MicrovmImageBuildDefinition(full_path="MyAgent", code_uri="./app", architecture="arm64") + d2 = MicrovmImageBuildDefinition(full_path="MyAgent", code_uri="./app", architecture="arm64") + self.assertEqual(d1, d2) + + def test_eq_different_full_path(self): + d1 = MicrovmImageBuildDefinition(full_path="AgentA", code_uri="./app", architecture="arm64") + d2 = MicrovmImageBuildDefinition(full_path="AgentB", code_uri="./app", architecture="arm64") + self.assertNotEqual(d1, d2) + + def test_eq_different_code_uri(self): + d1 = MicrovmImageBuildDefinition(full_path="MyAgent", code_uri="./app1", architecture="arm64") + d2 = MicrovmImageBuildDefinition(full_path="MyAgent", code_uri="./app2", architecture="arm64") + self.assertNotEqual(d1, d2) + + def test_get_resource_full_paths_with_image(self): + d = MicrovmImageBuildDefinition(full_path="MyAgent", code_uri="./app", architecture="arm64") + img = MicrovmImage(name="MyAgent", code_uri="./app", architecture="arm64") + d.add_microvm_image(img) + self.assertEqual(d.get_resource_full_paths(), "MyAgent") + + def test_microvm_image_not_persisted_to_build_toml(self): + """MicrovmImage build definitions are not serialized to build.toml (Docker has its own cache).""" + with osutils.mkdir_temp() as temp_base_dir: + build_dir = Path(temp_base_dir, ".aws-sam", "build") + build_dir.mkdir(parents=True) + + build_graph = BuildGraph(str(build_dir)) + + # Add a MicrovmImage definition + microvm_def = MicrovmImageBuildDefinition(full_path="MyAgent", code_uri="./app", architecture="arm64") + img = MicrovmImage(name="MyAgent", code_uri="./app", architecture="arm64") + build_graph.put_microvm_image_build_definition(microvm_def, img) + + # Write to build.toml + build_graph.clean_redundant_definitions_and_update(True) + + # Read build.toml content — should not contain MicrovmImage + build_toml_path = Path(build_dir.parent, "build.toml") + toml_content = build_toml_path.read_text() if build_toml_path.exists() else "" + self.assertNotIn("MyAgent", toml_content) + self.assertNotIn("microvm", toml_content.lower()) + + # Re-read build graph — should have no MicrovmImage definitions + build_graph_reloaded = BuildGraph(str(build_dir)) + self.assertEqual(len(build_graph_reloaded.get_microvm_image_build_definitions()), 0) diff --git a/tests/unit/lib/build_module/test_build_strategy.py b/tests/unit/lib/build_module/test_build_strategy.py index 8004923c324..c4d7c89c922 100644 --- a/tests/unit/lib/build_module/test_build_strategy.py +++ b/tests/unit/lib/build_module/test_build_strategy.py @@ -8,8 +8,13 @@ from samcli.lib.utils.architecture import X86_64, ARM64 from samcli.lib.build.exceptions import MissingBuildMethodException +from samcli.lib.build.build_graph import ( + BuildGraph, + FunctionBuildDefinition, + LayerBuildDefinition, + MicrovmImageBuildDefinition, +) from samcli.lib.build.workflow_config import UnsupportedRuntimeException -from samcli.lib.build.build_graph import BuildGraph, FunctionBuildDefinition, LayerBuildDefinition from samcli.lib.build.build_strategy import ( ParallelBuildStrategy, BuildStrategy, @@ -78,6 +83,9 @@ def build_single_function_definition(self, build_definition): def build_single_layer_definition(self, layer_definition): return {} + def build_single_microvm_image_definition(self, microvm_definition): + return {} + class BuildStrategyTest(BuildStrategyBaseTest): def test_build_functions_layers(self): @@ -200,19 +208,44 @@ def test_layer_build_should_fail_when_no_build_method_is_provided(self, mock_cop build_graph = Mock(spec=BuildGraph) build_graph.get_layer_build_definitions.return_value = [layer_build_definition] build_graph.get_function_build_definitions.return_value = [] + build_graph.get_microvm_image_build_definitions.return_value = [] mock_function = Mock() mock_function.inlinecode = None - default_build_strategy = DefaultBuildStrategy(build_graph, "build_dir", mock_function, Mock()) + default_build_strategy = DefaultBuildStrategy(build_graph, "build_dir", mock_function, Mock(), Mock()) self.assertRaises(MissingBuildMethodException, default_build_strategy.build) + def test_build_microvm_image(self, mock_copy_tree): + mock_build_microvm_image = Mock(return_value="myagent:latest") + + microvm_image = Mock() + microvm_image.full_path = "MyAgent" + microvm_image.architecture = "arm64" + + microvm_definition = MicrovmImageBuildDefinition("MyAgent", "./app", "arm64") + microvm_definition.microvm_image = microvm_image + + build_graph = Mock(spec=BuildGraph) + build_graph.get_function_build_definitions.return_value = [] + build_graph.get_layer_build_definitions.return_value = [] + build_graph.get_microvm_image_build_definitions.return_value = [microvm_definition] + + default_build_strategy = DefaultBuildStrategy( + build_graph, "build_dir", Mock(), Mock(), mock_build_microvm_image + ) + + result = default_build_strategy.build() + + mock_build_microvm_image.assert_called_once_with("MyAgent", "./app", "arm64") + self.assertEqual(result, {"MyAgent": "myagent:latest"}) + def test_build_layers_and_functions(self, mock_copy_tree): given_build_function = Mock() given_build_function.inlinecode = None given_build_layer = Mock() given_build_dir = "build_dir" default_build_strategy = DefaultBuildStrategy( - self.build_graph, given_build_dir, given_build_function, given_build_layer + self.build_graph, given_build_dir, given_build_function, given_build_layer, Mock() ) default_build_strategy.build() @@ -298,7 +331,7 @@ def test_dedup_build_functions_with_symlink(self, patched_is_experimental, mock_ given_build_layer = Mock() given_build_dir = "build_dir" default_build_strategy = DefaultBuildStrategy( - self.build_graph, given_build_dir, given_build_function, given_build_layer + self.build_graph, given_build_dir, given_build_function, given_build_layer, Mock() ) build_result = default_build_strategy.build() @@ -318,7 +351,7 @@ def test_build_single_function_definition_image_functions_with_same_metadata(sel given_build_layer = Mock() given_build_dir = "build_dir" default_build_strategy = DefaultBuildStrategy( - self.build_graph, given_build_dir, given_build_function, given_build_layer + self.build_graph, given_build_dir, given_build_function, given_build_layer, Mock() ) function1 = Mock() @@ -382,7 +415,7 @@ def test_build_call(self, mock_layer_build, mock_function_build, mock_rmtree, mo given_build_layer = Mock() given_build_dir = "build_dir" default_build_strategy = DefaultBuildStrategy( - self.build_graph, given_build_dir, given_build_function, given_build_layer + self.build_graph, given_build_dir, given_build_function, given_build_layer, Mock() ) cache_build_strategy = CachedBuildStrategy( self.build_graph, default_build_strategy, "base_dir", given_build_dir, "cache_dir" @@ -648,7 +681,7 @@ def setUp(self): self.build_layer = Mock() self.build_graph = Mock() self.delegate_build_strategy = DefaultBuildStrategy( - self.build_graph, Mock(), self.build_function, self.build_layer, cached=True + self.build_graph, Mock(), self.build_function, self.build_layer, Mock(), cached=True ) self.build_strategy = IncrementalBuildStrategy( self.build_graph, @@ -679,6 +712,7 @@ def test_assert_incremental_build_function(self, patched_manifest_hash, patched_ manifest_hash=build_toml_manifest_hash, functions=[Mock()], dependencies_dir=dependency_dir ) self.build_graph.get_function_build_definitions.return_value = [given_function_build_def] + self.build_graph.get_microvm_image_build_definitions.return_value = [] self.build_graph.get_layer_build_definitions.return_value = [] download_dependencies = manifest_hash != build_toml_manifest_hash or not dependency_dir_exist @@ -711,6 +745,7 @@ def test_assert_incremental_build_layer(self, patched_manifest_hash, patched_os, ) given_layer_build_def.layer.compatible_architectures = None self.build_graph.get_function_build_definitions.return_value = [] + self.build_graph.get_microvm_image_build_definitions.return_value = [] self.build_graph.get_layer_build_definitions.return_value = [given_layer_build_def] download_dependencies = manifest_hash != build_toml_manifest_hash or not dependency_dir_exist @@ -818,6 +853,7 @@ def test_exit_build_strategy_for_specific_resource( mocked_build_graph = Mock() mocked_build_graph.get_layer_build_definitions.return_value = [] mocked_build_graph.get_function_build_definitions.return_value = [] + mocked_build_graph.get_microvm_image_build_definitions.return_value = [] cached_build_strategy = CachedOrIncrementalBuildStrategyWrapper( mocked_build_graph, From 8e42a40e126d8b7e86847f1c4db45c90cd04bda3 Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Wed, 21 Oct 2026 12:44:56 -0700 Subject: [PATCH 02/23] fix(build): Resolve intrinsic CodeArtifact.Uri in the built template update_template only replaced an intrinsic MicrovmImage code path when the resolved value was a string. A hand-written CodeArtifact: {Uri: !Ref Src} resolves to {"Uri": "./app"}, so the built template kept {"Uri": {"Ref": "Src"}}. move_template cannot rebase a non-string path, so packaging would resolve it against .aws-sam/build/. Also accept a resolved {"Uri": } and add unit tests for the intrinsic replacement. --- samcli/lib/build/app_builder.py | 5 ++- .../unit/lib/build_module/test_app_builder.py | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index f106671c01f..46191df8665 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -413,13 +413,16 @@ def update_template( if has_build_artifact: # For MicrovmImage: if CodeUri is an intrinsic, replace it with the resolved literal # from stack.resources so that move_template can correctly rebase the path. + # CodeArtifact may also be the {"Uri": ...} structure, whose Uri is the path. if resource_type in (AWS_SERVERLESS_MICROVMIMAGE, AWS_LAMBDA_MICROVMIMAGE): code_prop = "CodeUri" if resource_type == AWS_SERVERLESS_MICROVMIMAGE else "CodeArtifact" code_value = properties.get(code_prop) if code_value and not isinstance(code_value, str): resolved_props = normalized_resources.get(logical_id, {}).get("Properties", {}) resolved_code = resolved_props.get(code_prop) - if isinstance(resolved_code, str): + if isinstance(resolved_code, str) or ( + isinstance(resolved_code, dict) and isinstance(resolved_code.get("Uri"), str) + ): properties[code_prop] = resolved_code ApplicationBuilder._update_built_resource( diff --git a/tests/unit/lib/build_module/test_app_builder.py b/tests/unit/lib/build_module/test_app_builder.py index bbe6cea5fc5..1802de454e1 100644 --- a/tests/unit/lib/build_module/test_app_builder.py +++ b/tests/unit/lib/build_module/test_app_builder.py @@ -1655,6 +1655,41 @@ def test_updates_web_function_code_uri(self): self.assertEqual(properties["CodeUri"], "build/WebFn") +class TestApplicationBuilder_update_template_resolves_microvm_intrinsic_code(TestCase): + @patch("samcli.lib.build.app_builder.get_validated_container_client") + def setUp(self, mock_get_validated_container_client): + mock_get_validated_container_client.return_value = Mock() + self.builder = ApplicationBuilder( + Mock(), "builddir", "basedir", "cachedir", stream_writer=StreamWriter(sys.stderr) + ) + + @parameterized.expand( + [ + ("serverless_code_uri", "AWS::Serverless::MicrovmImage", "CodeUri", {"Ref": "Src"}, "./app"), + ("lambda_code_artifact_string", "AWS::Lambda::MicrovmImage", "CodeArtifact", {"Ref": "Src"}, "./app"), + ( + "lambda_code_artifact_uri", + "AWS::Lambda::MicrovmImage", + "CodeArtifact", + {"Uri": {"Ref": "Src"}}, + {"Uri": "./app"}, + ), + ] + ) + def test_intrinsic_code_path_is_replaced_with_resolved_literal( + self, _name, resource_type, code_prop, raw_value, resolved_value + ): + template_dict = {"Resources": {"MyImage": {"Type": resource_type, "Properties": {code_prop: raw_value}}}} + resolved_resources = {"MyImage": {"Type": resource_type, "Properties": {code_prop: resolved_value}}} + stack = Mock( + stack_path="", template_dict=template_dict, resources=resolved_resources, location="/proj/template.yaml" + ) + + actual = self.builder.update_template(stack, {"MyImage": "myimage:latest"}, {}) + + self.assertEqual(actual["Resources"]["MyImage"]["Properties"][code_prop], resolved_value) + + class TestApplicationBuilder_update_template_windows(TestCase): @patch("samcli.lib.build.app_builder.get_validated_container_client") def setUp(self, mock_get_validated_container_client): From 09e410cf6c56e88a2ddc1b834bc7a0812f41d862 Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Wed, 21 Oct 2026 13:38:34 -0700 Subject: [PATCH 03/23] feat(build): Report the MicrovmImage tag in the JSON build output r4340571595: the local image sam build creates for a MicrovmImage is for local testing, but nothing reported its tag. The built template keeps the source directory, and the JSON entry had only the resource ID and architecture, so a tool had to copy the tagging rule from _build_microvm_image. Add image_tag to the entry, read from the build result so it is the tag that was actually built. --- samcli/commands/build/build_context.py | 4 +++ .../commands/buildcmd/test_build_context.py | 26 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/samcli/commands/build/build_context.py b/samcli/commands/build/build_context.py index 921304f08ae..62a340a94df 100644 --- a/samcli/commands/build/build_context.py +++ b/samcli/commands/build/build_context.py @@ -1182,6 +1182,7 @@ def _print_build_success( The functions and layers that were built """ if self._output is OutputOption.json: + built_artifacts = self._build_result.artifacts if self._build_result else {} # Emit absolute paths so a machine consumer never has to guess the process cwd. The # relpath transform below is only a human-readability affordance for the text banner. resources: List[Dict[str, Any]] = ( @@ -1199,6 +1200,9 @@ def _print_build_success( "resource_id": image.full_path, "type": "microvm_image", "architecture": image.architecture, + # The built template keeps the source directory, so this is the only place the + # local image's tag is reported + "image_tag": built_artifacts.get(image.full_path), } for image in resources_to_build.microvm_images ] diff --git a/tests/unit/commands/buildcmd/test_build_context.py b/tests/unit/commands/buildcmd/test_build_context.py index d521fb0df1d..aa6c6f87a09 100644 --- a/tests/unit/commands/buildcmd/test_build_context.py +++ b/tests/unit/commands/buildcmd/test_build_context.py @@ -24,7 +24,7 @@ from samcli.lib.build.exceptions import InvalidBuildGraphException 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.providers.provider import Function, MicrovmImage, 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 @@ -1792,6 +1792,30 @@ def test_json_with_functions_and_layers(self, echo_mock, secho_mock): 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.secho") + @patch("samcli.commands.build.build_context.click.echo") + def test_json_reports_the_microvm_image_tag(self, echo_mock, secho_mock): + collector = self._collector() + collector.add_microvm_image(MicrovmImage("MyImage", "src", "arm64", stack_path="Child")) + self.build_context._build_result = ApplicationBuildResult( + build_graph=Mock(), artifacts={"Child/MyImage": "child-myimage:latest"} + ) + + self.build_context._print_build_success("artifacts", "out_template", collector) + + result = json.loads(echo_mock.call_args[0][0]) + self.assertEqual( + result["resources"], + [ + { + "resource_id": "Child/MyImage", + "type": "microvm_image", + "architecture": "arm64", + "image_tag": "child-myimage:latest", + } + ], + ) + @patch("samcli.commands.build.build_context.click.secho") @patch("samcli.commands.build.build_context.click.echo") def test_json_reports_absolute_paths_unchanged(self, echo_mock, secho_mock): From 1f48ed34aebe4f9162e31fedf3ae4bb779ac1d39 Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Wed, 21 Oct 2026 16:43:45 -0700 Subject: [PATCH 04/23] feat(build): Warn on cross-architecture MicrovmImage builds MicrovmImage is built for linux/arm64, but Dockerfile RUN steps execute on the host CPU. On an x86 host without emulation they fail with "exec format error" and no hint why. The existing cross-architecture warning only covers functions built with --use-container. Warn when the host architecture differs from the image architecture, reusing _get_host_architecture. The build still proceeds, since hosts with QEMU (e.g. Docker Desktop) can complete it. --- samcli/lib/build/app_builder.py | 15 ++++++++++++++- .../unit/lib/build_module/test_app_builder.py | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index 46191df8665..cdb5b4a2617 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -39,7 +39,7 @@ DockerfileOutSideOfContext, UnsupportedBuilderLibraryVersionError, ) -from samcli.lib.build.utils import _make_env_vars, warn_cross_architecture_build +from samcli.lib.build.utils import _get_host_architecture, _make_env_vars, warn_cross_architecture_build from samcli.lib.build.workflow_config import ( CONFIG, UnsupportedRuntimeException, @@ -1050,6 +1050,19 @@ def _build_microvm_image(self, full_path: str, code_uri: str, architecture: str) f"Dockerfile not found in CodeUri directory '{docker_context_dir}' for MicrovmImage '{full_path}'." ) + # The image is built for the target platform, but RUN steps execute on the host CPU. + host_architecture = _get_host_architecture() + if host_architecture != architecture: + LOG.warning( + "Building MicrovmImage '%s' for %s on a %s host. Dockerfile RUN steps execute %s binaries " + "and need emulation (for example QEMU via binfmt_misc); without it the build fails with " + "'exec format error'.", + full_path, + architecture, + host_architecture, + architecture, + ) + docker_tag = f"{full_path.replace('/', '-').lower()}:latest" build_args = { diff --git a/tests/unit/lib/build_module/test_app_builder.py b/tests/unit/lib/build_module/test_app_builder.py index 1802de454e1..574ad82031d 100644 --- a/tests/unit/lib/build_module/test_app_builder.py +++ b/tests/unit/lib/build_module/test_app_builder.py @@ -3900,6 +3900,25 @@ def test_tag_is_lowercase(self, mock_exists): tag = builder._build_microvm_image("MyMicroVMApp", "./app", "arm64") self.assertEqual(tag, "mymicrovmapp:latest") + @parameterized.expand( + [ + ("x86_64_host", "x86_64", True), + ("arm64_host", "arm64", False), + ] + ) + @patch("samcli.lib.build.app_builder.LOG") + @patch("samcli.lib.build.app_builder._get_host_architecture") + @patch("samcli.lib.build.app_builder.pathlib.Path.exists", return_value=True) + def test_warns_on_cross_architecture_host(self, _name, host_arch, expect_warning, mock_exists, mock_host, mock_log): + mock_host.return_value = host_arch + builder = self._make_builder() + + builder._build_microvm_image("MyMicroVM", "./app", "arm64") + + warned = any("exec format error" in c.args[0] for c in mock_log.warning.call_args_list) + self.assertEqual(warned, expect_warning) + builder._image_build_client.build_image.assert_called_once() + class TestApplicationBuilder_update_built_resource_skips_microvm(TestCase): def test_skip_serverless_microvm_image(self): From b4581743aaef7d4b6ccfa81a891a1a91bfebba0c Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Wed, 21 Oct 2026 16:47:56 -0700 Subject: [PATCH 05/23] test(build): Exercise arm64 emulation in MicrovmImage integration tests The existing fixture only has COPY, so building it for linux/arm64 never runs arm64 code and the green x86 lanes did not show cross-arch builds work. Add a fixture with a RUN step and a test that builds it. Move the MicrovmImage integration tests from build-x86-2 to the build-arm64 lane, which sets up QEMU. The tier1 smoke test keeps the COPY-only fixture, since tier1 lanes do not set up emulation. --- .github/workflows/integration-tests.yml | 2 ++ .../integration/buildcmd/test_build_cmd_microvm_image.py | 8 ++++++++ .../testdata/buildcmd/MicrovmImageWithRun/Dockerfile | 4 ++++ 3 files changed, 14 insertions(+) create mode 100644 tests/integration/testdata/buildcmd/MicrovmImageWithRun/Dockerfile diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 74c72d91f2e..ee8913d1e75 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -319,6 +319,7 @@ jobs: --ignore=tests/integration/buildcmd/test_build_in_source.py \ --ignore=tests/integration/buildcmd/test_build_samconfig.py \ --ignore=tests/integration/buildcmd/test_build_cmd_arm64.py \ + --ignore=tests/integration/buildcmd/test_build_cmd_microvm_image.py \ --ignore=tests/integration/buildcmd/test_build_terraform_applications.py \ --ignore=tests/integration/buildcmd/test_build_terraform_applications_other_cases.py \ -k "not container" \ @@ -327,6 +328,7 @@ jobs: "build-arm64") pytest -vv -n 2 --reruns 3 -m "not requires_credential and not tier1_extra" \ tests/integration/buildcmd/test_build_cmd_arm64.py \ + tests/integration/buildcmd/test_build_cmd_microvm_image.py \ -k "not container" \ --json-report --json-report-file=TEST_REPORT-${{ matrix.test_suite }}.json ;; diff --git a/tests/integration/buildcmd/test_build_cmd_microvm_image.py b/tests/integration/buildcmd/test_build_cmd_microvm_image.py index 81b1c964664..e0198d8669a 100644 --- a/tests/integration/buildcmd/test_build_cmd_microvm_image.py +++ b/tests/integration/buildcmd/test_build_cmd_microvm_image.py @@ -92,6 +92,14 @@ def test_no_artifact_directory_created(self): build_dir_files = os.listdir(str(self.default_build_dir)) self.assertNotIn("MyMicrovmImage", build_dir_files) + def test_build_microvm_image_with_run_step_succeeds(self): + """A Dockerfile RUN step executes arm64 code, so on an x86 host this exercises emulation.""" + overrides = {"CodeUri": "./MicrovmImageWithRun"} + cmdlist = self.get_command_list(parameter_overrides=overrides, use_buildkit=self.use_buildkit) + command_result = run_command(cmdlist, cwd=self.working_dir) + + self.assertEqual(command_result.process.returncode, 0, command_result.stderr.decode()) + def test_build_fails_when_dockerfile_not_found(self): """sam build should fail when CodeUri directory doesn't contain a Dockerfile.""" overrides = {"CodeUri": "./NonExistentDirectory"} diff --git a/tests/integration/testdata/buildcmd/MicrovmImageWithRun/Dockerfile b/tests/integration/testdata/buildcmd/MicrovmImageWithRun/Dockerfile new file mode 100644 index 00000000000..7259885e587 --- /dev/null +++ b/tests/integration/testdata/buildcmd/MicrovmImageWithRun/Dockerfile @@ -0,0 +1,4 @@ +FROM python:3.12-slim + +# Executes an arm64 binary during the build, so on an x86 host it needs emulation. +RUN python -c "print(1)" From 771ecc4d0d1a29b39cf86d5ef8e09a37ddc92aa4 Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Fri, 23 Oct 2026 10:56:27 -0700 Subject: [PATCH 06/23] test(build): Cover MicrovmImage inside Fn::ForEach with language extensions MicrovmImage inside Fn::ForEach already builds with --language-extensions, because the language-extensions code derives its artifact-property and Mapping tables from RESOURCES_WITH_LOCAL_PATHS. Nothing pinned it, so add an integration test: each image builds from its own directory, and the built template keeps Fn::ForEach with per-image paths in the generated SAMCodeUriImages Mapping. The test fails with KeyError: 'Mappings' if AWS_SERVERLESS_MICROVMIMAGE is removed from RESOURCES_WITH_LOCAL_PATHS. --- .../buildcmd/test_build_cmd_microvm_image.py | 25 ++++++++++++++++++- .../microvm-image-foreach/Alpha/Dockerfile | 2 ++ .../microvm-image-foreach/Beta/Dockerfile | 2 ++ .../microvm-image-foreach/template.yaml | 16 ++++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 tests/integration/testdata/buildcmd/microvm-image-foreach/Alpha/Dockerfile create mode 100644 tests/integration/testdata/buildcmd/microvm-image-foreach/Beta/Dockerfile create mode 100644 tests/integration/testdata/buildcmd/microvm-image-foreach/template.yaml diff --git a/tests/integration/buildcmd/test_build_cmd_microvm_image.py b/tests/integration/buildcmd/test_build_cmd_microvm_image.py index e0198d8669a..6bd3822295d 100644 --- a/tests/integration/buildcmd/test_build_cmd_microvm_image.py +++ b/tests/integration/buildcmd/test_build_cmd_microvm_image.py @@ -100,6 +100,28 @@ def test_build_microvm_image_with_run_step_succeeds(self): self.assertEqual(command_result.process.returncode, 0, command_result.stderr.decode()) + def test_build_foreach_dynamic_code_uri(self): + """With --language-extensions, each Fn::ForEach image builds from its own directory and the + built template keeps Fn::ForEach, with per-image paths in a generated Mapping.""" + source_dir = Path(self.test_data_path, "microvm-image-foreach") + self.template_path = str(source_dir.joinpath("template.yaml")) + cmdlist = self.get_command_list(use_buildkit=self.use_buildkit) + ["--language-extensions"] + command_result = run_command(cmdlist, cwd=self.working_dir) + + self.assertEqual(command_result.process.returncode, 0, command_result.stderr.decode()) + + container_client = get_validated_container_client() + for tag in ("alphaimage:latest", "betaimage:latest"): + self.assertTrue(container_client.images.list(name=tag), f"Docker image '{tag}' should exist") + + with open(str(self.built_template), "r") as f: + built_template = yaml_parse(f.read()) + self.assertIn("Fn::ForEach::Images", built_template["Resources"]) + mapping = built_template["Mappings"]["SAMCodeUriImages"] + for name in ("Alpha", "Beta"): + built_path = Path(self.default_build_dir, mapping[name]["CodeUri"]).resolve() + self.assertEqual(built_path, source_dir.joinpath(name).resolve()) + def test_build_fails_when_dockerfile_not_found(self): """sam build should fail when CodeUri directory doesn't contain a Dockerfile.""" overrides = {"CodeUri": "./NonExistentDirectory"} @@ -113,7 +135,8 @@ def tearDown(self): # Clean up the image created during the test (works across Docker and Finch) try: container_client = get_validated_container_client() - container_client.remove_image_safely("mymicrovmimage:latest") + for tag in ("mymicrovmimage:latest", "alphaimage:latest", "betaimage:latest"): + container_client.remove_image_safely(tag) except Exception: pass super().tearDown() diff --git a/tests/integration/testdata/buildcmd/microvm-image-foreach/Alpha/Dockerfile b/tests/integration/testdata/buildcmd/microvm-image-foreach/Alpha/Dockerfile new file mode 100644 index 00000000000..47f710bc1a3 --- /dev/null +++ b/tests/integration/testdata/buildcmd/microvm-image-foreach/Alpha/Dockerfile @@ -0,0 +1,2 @@ +FROM python:3.12-slim +COPY . /app diff --git a/tests/integration/testdata/buildcmd/microvm-image-foreach/Beta/Dockerfile b/tests/integration/testdata/buildcmd/microvm-image-foreach/Beta/Dockerfile new file mode 100644 index 00000000000..47f710bc1a3 --- /dev/null +++ b/tests/integration/testdata/buildcmd/microvm-image-foreach/Beta/Dockerfile @@ -0,0 +1,2 @@ +FROM python:3.12-slim +COPY . /app diff --git a/tests/integration/testdata/buildcmd/microvm-image-foreach/template.yaml b/tests/integration/testdata/buildcmd/microvm-image-foreach/template.yaml new file mode 100644 index 00000000000..c5bbd15f385 --- /dev/null +++ b/tests/integration/testdata/buildcmd/microvm-image-foreach/template.yaml @@ -0,0 +1,16 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Resources: + Fn::ForEach::Images: + - Name + - [Alpha, Beta] + - ${Name}Image: + Type: AWS::Serverless::MicrovmImage + Properties: + Name: !Sub "${Name}-image" + CodeUri: ./${Name} + BaseImageArn: arn:aws:lambda:us-east-2:aws:microvm-image:al2023-1 + BaseImageVersion: "0" From 76d8ee53872d97ebf1a4bdacac3e05d32d8d3d7a Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Fri, 23 Oct 2026 11:32:46 -0700 Subject: [PATCH 07/23] fix(build): Point the built MicrovmImage at the directory it was built from With --base-dir, the MicrovmImage source is resolved from base_dir, but the built template kept the original CodeUri and move_template rebased it from the template's directory. For sam build -t infra/template.yaml --base-dir . with CodeUri: src/app, Docker built ./src/app while the built template pointed at infra/src/app, which does not exist. Record the absolute directory each image is built from, and have update_template write it relative to the template's directory so move_template rebases it correctly. This also covers intrinsic code paths and CodeArtifact: {Uri: ...}, so the separate intrinsic resolution is removed. update_template becomes an instance method; its only caller already calls it on an instance. --- samcli/lib/build/app_builder.py | 65 ++++++++++++++----- .../unit/lib/build_module/test_app_builder.py | 57 ++++++++++++---- 2 files changed, 94 insertions(+), 28 deletions(-) diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index cdb5b4a2617..8e0ce5de2f4 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -212,6 +212,9 @@ def __init__( self._mount_symlinks = mount_symlinks self._use_buildkit = use_buildkit self._image_build_client: Optional[ImageBuildClient] = None + # Absolute source directory each MicrovmImage was built from, keyed by full path. The built template + # points back at this directory, because the source (not the local image) is the deployment artifact. + self._microvm_image_source_dirs: Dict[str, str] = {} @property def _container_client(self) -> ContainerClient: @@ -347,8 +350,8 @@ def _get_build_graph( build_graph.clean_redundant_definitions_and_update(not self._is_building_specific_resource) return build_graph - @staticmethod def update_template( + self, stack: Stack, built_artifacts: Dict[str, str], stack_output_template_path_by_stack_path: Dict[str, str], @@ -411,19 +414,8 @@ def update_template( store_path = os.path.relpath(absolute_output_path, original_dir) if has_build_artifact: - # For MicrovmImage: if CodeUri is an intrinsic, replace it with the resolved literal - # from stack.resources so that move_template can correctly rebase the path. - # CodeArtifact may also be the {"Uri": ...} structure, whose Uri is the path. if resource_type in (AWS_SERVERLESS_MICROVMIMAGE, AWS_LAMBDA_MICROVMIMAGE): - code_prop = "CodeUri" if resource_type == AWS_SERVERLESS_MICROVMIMAGE else "CodeArtifact" - code_value = properties.get(code_prop) - if code_value and not isinstance(code_value, str): - resolved_props = normalized_resources.get(logical_id, {}).get("Properties", {}) - resolved_code = resolved_props.get(code_prop) - if isinstance(resolved_code, str) or ( - isinstance(resolved_code, dict) and isinstance(resolved_code.get("Uri"), str) - ): - properties[code_prop] = resolved_code + self._point_microvm_image_at_source_dir(full_path, resource_type, properties, original_dir) ApplicationBuilder._update_built_resource( built_artifacts[full_path], properties, resource_type or "", store_path @@ -438,11 +430,51 @@ def update_template( return template_dict + def _point_microvm_image_at_source_dir( + self, full_path: str, resource_type: str, properties: Dict, original_dir: pathlib.Path + ) -> None: + """ + Set a built MicrovmImage's code path to the directory it was actually built from. + + The path is written relative to the template's directory, which is what move_template expects + when it rebases paths into the build directory. Using the built directory, rather than the original + value, keeps the built template correct for intrinsics (!Ref), the CodeArtifact {Uri: ...} structure, + and --base-dir (where the source is resolved from base_dir, not from the template's directory). + + Parameters + ---------- + full_path : str + Stack-path-qualified identifier of the MicrovmImage + resource_type : str + AWS::Serverless::MicrovmImage or AWS::Lambda::MicrovmImage + properties : dict + Properties of the resource in the template being written, modified in place + original_dir : pathlib.Path + Directory of the template being written + """ + source_dir = self._microvm_image_source_dirs.get(full_path) + if not source_dir: + return + + # In Windows, if the template and the source are on different drives, relpath fails + source_path = ( + os.path.relpath(source_dir, original_dir) + if pathlib.Path(source_dir).drive == original_dir.drive + else source_dir + ) + + code_prop = "CodeUri" if resource_type == AWS_SERVERLESS_MICROVMIMAGE else "CodeArtifact" + code_value = properties.get(code_prop) + if resource_type == AWS_LAMBDA_MICROVMIMAGE and isinstance(code_value, dict) and "Uri" in code_value: + properties[code_prop] = {**code_value, "Uri": source_path} + else: + properties[code_prop] = source_path + @staticmethod def _update_built_resource(path: str, resource_properties: Dict, resource_type: str, absolute_path: str) -> None: - # MicrovmImage: Docker image is local-only for testing, built template is not updated. - # Deployment artifact is the zip of source directory (handled by sam package), not the Docker image. - # Intrinsic CodeUri is resolved to a literal in update_template before reaching here. + # MicrovmImage: the Docker image is local-only for testing, so the code path is not set to a build artifact. + # The deployment artifact is the zip of the source directory (handled by sam package), not the Docker image. + # update_template points the code path at the built source directory before reaching here. if resource_type in (AWS_SERVERLESS_MICROVMIMAGE, AWS_LAMBDA_MICROVMIMAGE): return if resource_type == AWS_SERVERLESS_FUNCTION and resource_properties.get("PackageType", ZIP) == ZIP: @@ -1108,6 +1140,7 @@ def _build_microvm_image(self, full_path: str, code_uri: str, architecture: str) raise DockerfileOutSideOfContext(e.explanation) from e raise + self._microvm_image_source_dirs[full_path] = str(docker_context_dir) return docker_tag def _build_function_in_process( diff --git a/tests/unit/lib/build_module/test_app_builder.py b/tests/unit/lib/build_module/test_app_builder.py index 574ad82031d..0948adcbcef 100644 --- a/tests/unit/lib/build_module/test_app_builder.py +++ b/tests/unit/lib/build_module/test_app_builder.py @@ -1655,39 +1655,70 @@ def test_updates_web_function_code_uri(self): self.assertEqual(properties["CodeUri"], "build/WebFn") -class TestApplicationBuilder_update_template_resolves_microvm_intrinsic_code(TestCase): +class TestApplicationBuilder_update_template_microvm_code_path(TestCase): + """update_template points a built MicrovmImage at the directory it was built from.""" + @patch("samcli.lib.build.app_builder.get_validated_container_client") def setUp(self, mock_get_validated_container_client): mock_get_validated_container_client.return_value = Mock() self.builder = ApplicationBuilder( Mock(), "builddir", "basedir", "cachedir", stream_writer=StreamWriter(sys.stderr) ) + self.proj = os.path.abspath(os.path.join(os.sep, "proj")) + + def _update(self, resource_type, code_prop, raw_value, template_dir, source_dir): + template_dict = {"Resources": {"MyImage": {"Type": resource_type, "Properties": {code_prop: raw_value}}}} + stack = Mock( + stack_path="", + template_dict=template_dict, + resources={}, + location=os.path.join(template_dir, "template.yaml"), + ) + self.builder._microvm_image_source_dirs = {"MyImage": source_dir} + actual = self.builder.update_template(stack, {"MyImage": "myimage:latest"}, {}) + return actual["Resources"]["MyImage"]["Properties"][code_prop] @parameterized.expand( [ - ("serverless_code_uri", "AWS::Serverless::MicrovmImage", "CodeUri", {"Ref": "Src"}, "./app"), - ("lambda_code_artifact_string", "AWS::Lambda::MicrovmImage", "CodeArtifact", {"Ref": "Src"}, "./app"), + ("serverless_code_uri", "AWS::Serverless::MicrovmImage", "CodeUri", {"Ref": "Src"}, "app"), + ("lambda_code_artifact_string", "AWS::Lambda::MicrovmImage", "CodeArtifact", {"Ref": "Src"}, "app"), ( "lambda_code_artifact_uri", "AWS::Lambda::MicrovmImage", "CodeArtifact", {"Uri": {"Ref": "Src"}}, - {"Uri": "./app"}, + {"Uri": "app"}, ), ] ) - def test_intrinsic_code_path_is_replaced_with_resolved_literal( - self, _name, resource_type, code_prop, raw_value, resolved_value + def test_intrinsic_code_path_is_replaced_with_built_directory( + self, _name, resource_type, code_prop, raw_value, expected ): - template_dict = {"Resources": {"MyImage": {"Type": resource_type, "Properties": {code_prop: raw_value}}}} - resolved_resources = {"MyImage": {"Type": resource_type, "Properties": {code_prop: resolved_value}}} + actual = self._update(resource_type, code_prop, raw_value, self.proj, os.path.join(self.proj, "app")) + self.assertEqual(actual, expected) + + def test_base_dir_source_is_written_relative_to_template_directory(self): + # sam build -t infra/template.yaml --base-dir . with CodeUri: src/app builds /src/app. + # The template lives in /infra, so the path must be ../src/app, not src/app (which + # move_template would resolve to /infra/src/app). + actual = self._update( + "AWS::Serverless::MicrovmImage", + "CodeUri", + "src/app", + os.path.join(self.proj, "infra"), + os.path.join(self.proj, "src", "app"), + ) + self.assertEqual(actual, os.path.join("..", "src", "app")) + + def test_image_without_recorded_source_directory_is_left_unchanged(self): + template_dict = { + "Resources": {"MyImage": {"Type": "AWS::Serverless::MicrovmImage", "Properties": {"CodeUri": "app"}}} + } stack = Mock( - stack_path="", template_dict=template_dict, resources=resolved_resources, location="/proj/template.yaml" + stack_path="", template_dict=template_dict, resources={}, location=os.path.join(self.proj, "t.yaml") ) - actual = self.builder.update_template(stack, {"MyImage": "myimage:latest"}, {}) - - self.assertEqual(actual["Resources"]["MyImage"]["Properties"][code_prop], resolved_value) + self.assertEqual(actual["Resources"]["MyImage"]["Properties"]["CodeUri"], "app") class TestApplicationBuilder_update_template_windows(TestCase): @@ -3850,6 +3881,7 @@ def _make_builder(self, microvm_images=None, base_dir="/project"): builder._image_build_client.build_image.return_value = [] builder._validated_container_client = MagicMock() builder._stream_writer = MagicMock() + builder._microvm_image_source_dirs = {} return builder @patch("samcli.lib.build.app_builder.pathlib.Path.exists", return_value=True) @@ -3865,6 +3897,7 @@ def test_build_microvm_image_calls_docker_build(self, mock_exists): self.assertEqual(call_kwargs["tag"], "mymicrovm:latest") self.assertEqual(call_kwargs["dockerfile"], "Dockerfile") self.assertEqual(call_kwargs["platform"], "linux/arm64") + self.assertEqual(builder._microvm_image_source_dirs, {"MyMicroVM": call_kwargs["path"]}) @patch("samcli.lib.build.app_builder.pathlib.Path.exists", return_value=True) def test_build_microvm_image_calls_docker_build_nested_stack(self, mock_exists): From 9933da71feed967d790c7102cc467291d0db593f Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Fri, 23 Oct 2026 11:46:09 -0700 Subject: [PATCH 08/23] fix(package): Fail sam package for local MicrovmImage code until supported MicrovmImage types are in RESOURCES_WITH_LOCAL_PATHS so sam build can rebase their source path, but sam package has no exporter for them. On an image-only project it reported "Successfully packaged artifacts", left the local path in the packaged template, and suggested sam deploy. Register placeholder exporters that fail with a clear message when the code path is local. S3 URIs and unresolved intrinsics pass through unchanged. The upcoming sam package support replaces these with real exporters. --- samcli/lib/package/packageable_resources.py | 49 +++++++++++++++++++ .../lib/package/test_artifact_exporter.py | 49 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/samcli/lib/package/packageable_resources.py b/samcli/lib/package/packageable_resources.py index 528817e0923..bedb6129c07 100644 --- a/samcli/lib/package/packageable_resources.py +++ b/samcli/lib/package/packageable_resources.py @@ -41,11 +41,13 @@ AWS_GLUE_JOB, AWS_LAMBDA_FUNCTION, AWS_LAMBDA_LAYERVERSION, + AWS_LAMBDA_MICROVMIMAGE, AWS_SERVERLESS_API, AWS_SERVERLESS_FUNCTION, AWS_SERVERLESS_GRAPHQLAPI, AWS_SERVERLESS_HTTPAPI, AWS_SERVERLESS_LAYERVERSION, + AWS_SERVERLESS_MICROVMIMAGE, AWS_SERVERLESS_STATEMACHINE, AWS_SERVERLESS_WEBFUNCTION, AWS_SERVERLESSREPO_APPLICATION, @@ -726,6 +728,51 @@ def export(self, resource_id: str, resource_dict: Optional[Dict], parent_dir: st shutil.rmtree(temp_dir) +class MicrovmImageResource(Resource): + """ + Placeholder until sam package can upload MicrovmImage code. + + MicrovmImage types are in RESOURCES_WITH_LOCAL_PATHS so that sam build rebases their source path, but + without an exporter sam package would report success and leave a local path in the packaged template. + Fail for a local path instead. S3 URIs and unresolved intrinsics are left unchanged. + """ + + EXPORT_DESTINATION = Destination.S3 + ARTIFACT_TYPE = ZIP + + def export(self, resource_id: str, resource_dict: Optional[Dict], parent_dir: str) -> None: + if resource_dict is None or resource_not_packageable(resource_dict): + return + + property_value = resource_dict.get(self.PROPERTY_NAME) + if isinstance(property_value, dict): + # AWS::Lambda::MicrovmImage CodeArtifact: {Uri: ...} + property_value = property_value.get("Uri") + if not isinstance(property_value, str) or not property_value or is_s3_protocol_url(property_value): + return + + raise exceptions.ExportFailedError( + resource_id=resource_id, + property_name=self.PROPERTY_NAME, + property_value=property_value, + ex=f"sam package does not support local code for {self.RESOURCE_TYPE} yet. Upload the source " + f"directory as a zip to S3 and set {self.PROPERTY_NAME} to its S3 URI.", + ) + + def get_property_value(self, resource_dict: Optional[Dict]) -> Dict[str, Optional[str]]: + return {"Bucket": None, "Key": None} + + +class ServerlessMicrovmImageResource(MicrovmImageResource): + RESOURCE_TYPE = AWS_SERVERLESS_MICROVMIMAGE + PROPERTY_NAME = RESOURCES_WITH_LOCAL_PATHS[AWS_SERVERLESS_MICROVMIMAGE][0] + + +class LambdaMicrovmImageResource(MicrovmImageResource): + RESOURCE_TYPE = AWS_LAMBDA_MICROVMIMAGE + PROPERTY_NAME = RESOURCES_WITH_LOCAL_PATHS[AWS_LAMBDA_MICROVMIMAGE][0] + + RESOURCES_EXPORT_LIST = [ ServerlessFunctionResource, ServerlessFunctionImageResource, @@ -754,6 +801,8 @@ def export(self, resource_id: str, resource_dict: Optional[Dict], parent_dir: st ECRResource, GraphQLApiSchemaResource, GraphQLApiCodeResource, + ServerlessMicrovmImageResource, + LambdaMicrovmImageResource, ] diff --git a/tests/unit/lib/package/test_artifact_exporter.py b/tests/unit/lib/package/test_artifact_exporter.py index 506dc3c26fa..a7e411baa97 100644 --- a/tests/unit/lib/package/test_artifact_exporter.py +++ b/tests/unit/lib/package/test_artifact_exporter.py @@ -38,6 +38,8 @@ from samcli.lib.package.language_extensions_packaging import merge_language_extensions_s3_uris from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable from samcli.lib.package.packageable_resources import ( + LambdaMicrovmImageResource, + ServerlessMicrovmImageResource, GraphQLApiCodeResource, GraphQLApiSchemaResource, is_s3_protocol_url, @@ -3333,3 +3335,50 @@ def test_off_path_does_not_invoke_pre_le_global_transform_pass(self): stack_resource.do_export("ChildStack", {"TemplateURL": child_path}, parent_dir) mock_pre_pass.assert_not_called() + + +class TestMicrovmImageResourcePlaceholder(unittest.TestCase): + """Until sam package supports MicrovmImage, a local code path fails instead of being left in place.""" + + def setUp(self): + self.uploaders = Mock() + + def _export(self, exporter_class, property_value): + resource_dict = {exporter_class.PROPERTY_NAME: copy.deepcopy(property_value)} + exporter_class(self.uploaders, None).export("MyImage", resource_dict, "/proj") + return resource_dict + + def test_local_code_uri_fails(self): + with self.assertRaisesRegex(ExportFailedError, "does not support local code for AWS::Serverless::Microvm"): + self._export(ServerlessMicrovmImageResource, "./app") + + def test_local_code_artifact_string_and_uri_fail(self): + for value in ("./app", {"Uri": "./app"}): + with self.subTest(value=value): + with self.assertRaisesRegex(ExportFailedError, "set CodeArtifact to its S3 URI"): + self._export(LambdaMicrovmImageResource, value) + + def test_s3_and_intrinsic_values_are_unchanged(self): + cases = [ + (ServerlessMicrovmImageResource, "s3://bucket/app.zip"), + (ServerlessMicrovmImageResource, {"Ref": "Src"}), + (LambdaMicrovmImageResource, {"Uri": "s3://bucket/app.zip"}), + (LambdaMicrovmImageResource, {"Uri": {"Ref": "Src"}}), + ] + for exporter_class, value in cases: + with self.subTest(exporter=exporter_class.__name__, value=value): + resource_dict = self._export(exporter_class, value) + self.assertEqual(resource_dict[exporter_class.PROPERTY_NAME], value) + self.uploaders.get.assert_not_called() + + def test_template_export_fails_for_local_microvm_image(self): + template_dict = { + "Resources": { + "MyImage": {"Type": "AWS::Serverless::MicrovmImage", "Properties": {"CodeUri": "./app"}}, + } + } + template = Template( + "template.yaml", os.getcwd(), self.uploaders, Mock(), template_dict=copy.deepcopy(template_dict) + ) + with self.assertRaisesRegex(ExportFailedError, "does not support local code"): + template.export() From 81f5c14932b39f9007f51e336b242d56d3bacd24 Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Fri, 23 Oct 2026 14:28:03 -0700 Subject: [PATCH 09/23] feat(build): List built MicrovmImages in the text build summary The text output never named the local image a MicrovmImage build creates; the tag only appeared in the Docker stream, and "Built Artifacts" holds nothing for it. The suggested next steps also offered sam local invoke, which fails on a project with no functions. Add a "Built MicrovmImages" section listing each image's tag and platform, read from the build result. When only MicrovmImages were built, drop the sam local invoke suggestion. Output is unchanged when no MicrovmImage is built. --- samcli/commands/build/build_context.py | 39 +++++++++- .../commands/buildcmd/test_build_context.py | 73 ++++++++++++++++++- 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/samcli/commands/build/build_context.py b/samcli/commands/build/build_context.py index 62a340a94df..b6e23c0daaa 100644 --- a/samcli/commands/build/build_context.py +++ b/samcli/commands/build/build_context.py @@ -10,7 +10,7 @@ import pathlib import shutil from collections import Counter -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, cast import click @@ -78,6 +78,7 @@ from samcli.lib.utils.osutils import BUILD_DIR_PERMISSIONS from samcli.lib.utils.path_utils import is_same_or_ancestor from samcli.local.docker.manager import ContainerManager +from samcli.local.docker.utils import get_docker_platform from samcli.local.lambdafn.exceptions import ( FunctionNotFound, ResourceNotFound, @@ -1234,6 +1235,7 @@ def _print_build_success( artifacts_dir, output_template_path, os.path.abspath(self.build_dir) == os.path.abspath(DEFAULT_BUILD_DIR), + resources_to_build, ) click.secho(msg, fg="yellow") @@ -1248,7 +1250,13 @@ def _print_build_failure(self) -> None: 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: + def _gen_success_msg( + self, + artifacts_dir: str, + output_template_path: str, + is_default_build_dir: bool, + resources_to_build: Optional[ResourcesToBuildCollector] = None, + ) -> str: """ Generates a success message containing some suggested commands to run @@ -1260,6 +1268,8 @@ def _gen_success_msg(self, artifacts_dir: str, output_template_path: str, is_def A string path representing the final template file is_default_build_dir: bool True if the build folder is the folder defined by SAM CLI + resources_to_build: Optional[ResourcesToBuildCollector] + The resources that were built, used to list built MicrovmImages and tailor the suggestions Returns ------- @@ -1277,7 +1287,18 @@ def _gen_success_msg(self, artifacts_dir: str, output_template_path: str, is_def invoke_suggestion += " -t {}".format(output_template_path) deploy_suggestion += " --template-file {}".format(output_template_path) - commands = [validate_suggestion, invoke_suggestion, sync_suggestion, deploy_suggestion] + microvm_images = resources_to_build.microvm_images if resources_to_build else [] + only_microvm_images = ( + resources_to_build is not None + and bool(microvm_images) + and not resources_to_build.functions + and not resources_to_build.layers + ) + if only_microvm_images: + # There is no function to invoke when only MicrovmImages were built + commands = [validate_suggestion, sync_suggestion, deploy_suggestion] + else: + commands = [validate_suggestion, invoke_suggestion, sync_suggestion, deploy_suggestion] # check if we have used a hook package before building if self._hook_name: @@ -1290,7 +1311,19 @@ def _gen_success_msg(self, artifacts_dir: str, output_template_path: str, is_def msg = f"""\nBuilt Artifacts : {artifacts_dir} Built Template : {output_template_path} +""" + + if microvm_images: + # The local image is not in the build directory, so name its tag here + built_artifacts = self._build_result.artifacts if self._build_result else {} + image_lines = [ + f"{image.full_path} -> {built_artifacts.get(image.full_path)} " + f"({get_docker_platform(cast(str, image.architecture))})" + for image in microvm_images + ] + msg += "\nBuilt MicrovmImages\n===================\n[*] " + f"{os.linesep}[*] ".join(image_lines) + "\n" + msg += """ Commands you can use next ========================= """ diff --git a/tests/unit/commands/buildcmd/test_build_context.py b/tests/unit/commands/buildcmd/test_build_context.py index aa6c6f87a09..519c19751b0 100644 --- a/tests/unit/commands/buildcmd/test_build_context.py +++ b/tests/unit/commands/buildcmd/test_build_context.py @@ -1146,7 +1146,7 @@ def test_run_build_context( root_stack.stack_path: "./build_dir/template.yaml", child_stack.stack_path: "./build_dir/abcd/template.yaml", } - resources_mock.return_value = Mock(functions=[], layers=[]) + resources_mock.return_value = Mock(functions=[], layers=[], microvm_images=[]) builder_mock = ApplicationBuilderMock.return_value = Mock() artifacts = "artifacts" @@ -1385,7 +1385,7 @@ def test_must_catch_known_exceptions( SamMicrovmImageProviderMock.return_value.get.return_value = None SamMicrovmImageProviderMock.return_value.get_all.return_value = [] stack = Mock() - resources_mock.return_value = Mock(functions=[], layers=[]) + resources_mock.return_value = Mock(functions=[], layers=[], microvm_images=[]) builder_mock = ApplicationBuilderMock.return_value = Mock() artifacts = builder_mock.build.return_value = "artifacts" @@ -1738,6 +1738,75 @@ def test_gen_message_with_non_default_build_with_hook_package(self): self.assertEqual(msg, expected_msg) +class TestBuildContext_gen_success_msg_microvm_images(TestCase): + def setUp(self): + self.build_context = BuildContext( + resource_identifier=None, + template_file="template_file", + base_dir="base_dir", + build_dir="build_dir", + cache_dir="cache_dir", + parallel=False, + mode="mode", + cached=False, + ) + self.build_context._hook_name = False + self.images = [ + MicrovmImage(name="MyImage", code_uri="./app", architecture="arm64"), + MicrovmImage(name="Other", code_uri="./other", architecture="arm64", stack_path="Child"), + ] + self.build_context._build_result = ApplicationBuildResult( + Mock(), {"MyImage": "myimage:latest", "Child/Other": "child-other:latest"} + ) + + def _collector(self, functions=(), layers=(), images=()): + collector = ResourcesToBuildCollector() + collector.add_functions(list(functions)) + collector.add_layers(list(layers)) + collector.add_microvm_images(list(images)) + return collector + + def test_lists_built_microvm_images_with_tag_and_platform(self): + msg = self.build_context._gen_success_msg( + "build_dir", "template_file", True, self._collector(images=self.images) + ) + + self.assertIn( + "\nBuilt MicrovmImages\n===================\n" + f"[*] MyImage -> myimage:latest (linux/arm64){os.linesep}" + "[*] Child/Other -> child-other:latest (linux/arm64)\n", + msg, + ) + + def test_image_only_build_drops_local_invoke_but_keeps_sync_and_deploy(self): + msg = self.build_context._gen_success_msg( + "build_dir", "template_file", True, self._collector(images=self.images) + ) + + self.assertNotIn("sam local invoke", msg) + self.assertIn("sam validate", msg) + self.assertIn("sam sync", msg) + self.assertIn("sam deploy --guided", msg) + + def test_mixed_build_keeps_local_invoke(self): + function = Mock() + msg = self.build_context._gen_success_msg( + "build_dir", "template_file", True, self._collector(functions=[function], images=self.images) + ) + + self.assertIn("sam local invoke", msg) + self.assertIn("Built MicrovmImages", msg) + + def test_no_microvm_images_leaves_message_unchanged(self): + with_collector = self.build_context._gen_success_msg( + "build_dir", "template_file", True, self._collector(functions=[Mock()]) + ) + without_collector = self.build_context._gen_success_msg("build_dir", "template_file", True) + + self.assertEqual(with_collector, without_collector) + self.assertNotIn("Built MicrovmImages", with_collector) + + class TestBuildContext_print_build_success(TestCase): def setUp(self): self.build_dir = ".aws-sam/build" From 49c5b31e91a1d30b4ceaefbfc16caca35fe50c25 Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Fri, 23 Oct 2026 16:08:32 -0700 Subject: [PATCH 10/23] fix(build): Sanitize MicrovmImage tags and report other Docker API errors A CDK construct ID can contain characters Docker rejects in an image name, so a resource ID such as "ucb space img" produced the invalid tag "ucb space img:latest" and an unhandled traceback. Build the tag from the full path with every run of non-alphanumeric characters replaced by "-". Existing tags are unchanged. _build_microvm_image re-raised any docker APIError other than a Dockerfile-not-found error, which surfaced as an unexpected error asking for a bug report. Raise DockerBuildFailed with the daemon's explanation instead, so it is reported as a build failure attributed to the resource. --- samcli/lib/build/app_builder.py | 23 +++++++++--- .../unit/lib/build_module/test_app_builder.py | 35 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index 8e0ce5de2f4..d6eb159e8f5 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -7,6 +7,7 @@ import logging import os import pathlib +import re from pathlib import Path from typing import Dict, List, NamedTuple, Optional, cast @@ -1047,13 +1048,25 @@ def _get_working_directory_path( return working_directory + @staticmethod + def _microvm_image_tag(full_path: str) -> str: + """ + Local Docker tag for a MicrovmImage: :latest. + + CDK construct IDs (used as the resource ID) can contain characters Docker rejects in an image + name, such as spaces, so every run of non-alphanumeric characters (including the nested stack "/") + becomes a single "-". + """ + name = re.sub(r"[^a-z0-9]+", "-", full_path.lower()).strip("-") or "microvm-image" + return f"{name}:latest" + def _build_microvm_image(self, full_path: str, code_uri: str, architecture: str) -> str: """ Build a Docker image for a MicrovmImage resource. Uses CodeUri as the Docker build context. Dockerfile must be in that directory. - Tags the image as :latest, so that - MicrovmImage resources with the same logical ID in different nested stacks don't collide + Tags the image with _microvm_image_tag (the full path, lowercased and with "/" turned into "-"), + so that MicrovmImage resources with the same logical ID in different nested stacks don't collide on the same local Docker tag. Does NOT update the built template. @@ -1095,7 +1108,7 @@ def _build_microvm_image(self, full_path: str, code_uri: str, architecture: str) architecture, ) - docker_tag = f"{full_path.replace('/', '-').lower()}:latest" + docker_tag = self._microvm_image_tag(full_path) build_args = { "path": str(docker_context_dir), @@ -1131,14 +1144,14 @@ def _build_microvm_image(self, full_path: str, code_uri: str, architecture: str) except docker.errors.APIError as e: if self._container_client.is_dockerfile_error(e): raise DockerfileOutSideOfContext(e.explanation) from e - raise + raise DockerBuildFailed(f"{full_path} failed to build: {e.explanation or e}") from e try: self._stream_lambda_image_build_logs(build_logs, full_path) # type: ignore[arg-type] except docker.errors.APIError as e: if self._container_client.is_dockerfile_error(e): raise DockerfileOutSideOfContext(e.explanation) from e - raise + raise DockerBuildFailed(f"{full_path} failed to build: {e.explanation or e}") from e self._microvm_image_source_dirs[full_path] = str(docker_context_dir) return docker_tag diff --git a/tests/unit/lib/build_module/test_app_builder.py b/tests/unit/lib/build_module/test_app_builder.py index 0948adcbcef..d4a30141c53 100644 --- a/tests/unit/lib/build_module/test_app_builder.py +++ b/tests/unit/lib/build_module/test_app_builder.py @@ -3933,6 +3933,41 @@ def test_tag_is_lowercase(self, mock_exists): tag = builder._build_microvm_image("MyMicroVMApp", "./app", "arm64") self.assertEqual(tag, "mymicrovmapp:latest") + @parameterized.expand( + [ + ("logical_id", "MyImage", "myimage:latest"), + ("nested_stack", "ChildStackA/MyMicroVM", "childstacka-mymicrovm:latest"), + ("cdk_id_with_spaces", "ucb space img", "ucb-space-img:latest"), + ("dots_and_underscores", "my_image..v2", "my-image-v2:latest"), + ("leading_and_trailing", " /Img/ ", "img:latest"), + ("nothing_valid", " ", "microvm-image:latest"), + ] + ) + def test_microvm_image_tag_is_a_valid_docker_name(self, _name, full_path, expected): + self.assertEqual(ApplicationBuilder._microvm_image_tag(full_path), expected) + + @patch("samcli.lib.build.app_builder.pathlib.Path.exists", return_value=True) + def test_api_error_becomes_docker_build_failed(self, mock_exists): + builder = self._make_builder() + builder._validated_container_client.is_dockerfile_error.return_value = False + builder._image_build_client.build_image.side_effect = docker.errors.APIError( + "400 Client Error", explanation="dockerfile parse error on line 3: unknown instruction: RUNN" + ) + + with self.assertRaisesRegex(DockerBuildFailed, "MyMicroVM failed to build: dockerfile parse error"): + builder._build_microvm_image("MyMicroVM", "./app", "arm64") + + @patch("samcli.lib.build.app_builder.pathlib.Path.exists", return_value=True) + def test_dockerfile_api_error_is_still_dockerfile_outside_of_context(self, mock_exists): + builder = self._make_builder() + builder._validated_container_client.is_dockerfile_error.return_value = True + builder._image_build_client.build_image.side_effect = docker.errors.APIError( + "500 Server Error", explanation="Cannot locate specified Dockerfile: Dockerfile" + ) + + with self.assertRaises(DockerfileOutSideOfContext): + builder._build_microvm_image("MyMicroVM", "./app", "arm64") + @parameterized.expand( [ ("x86_64_host", "x86_64", True), From 2c3f467ac9995e5b35dc105bdaa8b9b0f8304a23 Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Fri, 23 Oct 2026 16:27:01 -0700 Subject: [PATCH 11/23] fix(build): Report specific MicrovmImage source errors "Dockerfile not found in CodeUri directory" was shown when the directory did not exist (including an unresolved parameter such as '.../CodeUriParam'), when CodeUri was a file such as a .zip, and when the Dockerfile was missing. It was also raised as DockerBuildFailed, although no Docker build had run. - Distinguish a missing directory (with a --parameter-overrides hint), a file, and a missing Dockerfile - Raise a new MicrovmImageSourceError for these pre-build checks - Record why the provider skipped an image (S3 source, missing or invalid code property), so sam build reports that reason instead of "Unable to find resource" --- samcli/commands/build/build_context.py | 5 +++ samcli/lib/build/app_builder.py | 14 ++++++- samcli/lib/build/exceptions.py | 7 ++++ .../providers/sam_microvm_image_provider.py | 40 ++++++++++++++++++- .../buildcmd/test_build_cmd_microvm_image.py | 6 +-- .../commands/buildcmd/test_build_context.py | 27 ++++++++++++- .../lib/test_sam_microvm_image_provider.py | 31 ++++++++++++++ .../unit/lib/build_module/test_app_builder.py | 34 ++++++++++++++-- 8 files changed, 153 insertions(+), 11 deletions(-) diff --git a/samcli/commands/build/build_context.py b/samcli/commands/build/build_context.py index b6e23c0daaa..7bf5cca7dc7 100644 --- a/samcli/commands/build/build_context.py +++ b/samcli/commands/build/build_context.py @@ -40,6 +40,7 @@ from samcli.lib.build.exceptions import ( BuildInsideContainerError, InvalidBuildGraphException, + MicrovmImageSourceError, ) from samcli.lib.build.workflow_config import UnsupportedBuilderException, UnsupportedRuntimeException from samcli.lib.cfn_language_extensions.models import PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES @@ -1634,6 +1635,10 @@ def _collect_single_microvm_image( """ image = self.microvm_image_provider.get(resource_identifier) if not image: + # A targeted image skipped at extraction (e.g. an S3 source) would otherwise be reported as not found + skip_reason = self.microvm_image_provider.get_skip_reason(resource_identifier) + if isinstance(skip_reason, str) and not resource_collector.functions and not resource_collector.layers: + raise MicrovmImageSourceError(f"MicrovmImage '{resource_identifier}' is not buildable: {skip_reason}.") return # Hard fail only for truly unbuildable cases (unsupported architecture) if image.architecture is None: diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index d6eb159e8f5..3ebc8b578ed 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -38,6 +38,7 @@ BuildInsideContainerError, DockerBuildFailed, DockerfileOutSideOfContext, + MicrovmImageSourceError, UnsupportedBuilderLibraryVersionError, ) from samcli.lib.build.utils import _get_host_architecture, _make_env_vars, warn_cross_architecture_build @@ -1090,8 +1091,19 @@ def _build_microvm_image(self, full_path: str, code_uri: str, architecture: str) docker_context_dir = pathlib.Path(self._base_dir, code_uri).resolve() dockerfile_path = docker_context_dir / "Dockerfile" + # These checks run before any Docker build, so they raise MicrovmImageSourceError, not DockerBuildFailed + if not docker_context_dir.exists(): + raise MicrovmImageSourceError( + f"CodeUri directory '{docker_context_dir}' for MicrovmImage '{full_path}' does not exist. " + "If CodeUri references a template parameter, provide its value via --parameter-overrides." + ) + if not docker_context_dir.is_dir(): + raise MicrovmImageSourceError( + f"CodeUri '{docker_context_dir}' for MicrovmImage '{full_path}' is a file. CodeUri must be a " + "directory containing a Dockerfile; sam build cannot build a MicrovmImage from a .zip file." + ) if not dockerfile_path.exists(): - raise DockerBuildFailed( + raise MicrovmImageSourceError( f"Dockerfile not found in CodeUri directory '{docker_context_dir}' for MicrovmImage '{full_path}'." ) diff --git a/samcli/lib/build/exceptions.py b/samcli/lib/build/exceptions.py index 0677228941e..f13e80cc4fa 100644 --- a/samcli/lib/build/exceptions.py +++ b/samcli/lib/build/exceptions.py @@ -45,6 +45,13 @@ def __init__(self, msg: str) -> None: BuildError.__init__(self, "DockerBuildFailed", msg) +class MicrovmImageSourceError(BuildError): + """A MicrovmImage's source can't be built locally; raised before any Docker build runs.""" + + def __init__(self, msg: str) -> None: + BuildError.__init__(self, "MicrovmImageSourceError", msg) + + class MissingBuildMethodException(BuildError): def __init__(self, msg: str) -> None: BuildError.__init__(self, "MissingBuildMethodException", msg) diff --git a/samcli/lib/providers/sam_microvm_image_provider.py b/samcli/lib/providers/sam_microvm_image_provider.py index 500b31cff78..4a4199d90df 100644 --- a/samcli/lib/providers/sam_microvm_image_provider.py +++ b/samcli/lib/providers/sam_microvm_image_provider.py @@ -1,9 +1,9 @@ """Provider for AWS::Serverless::MicrovmImage resources.""" import logging -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Set, Tuple -from samcli.lib.providers.provider import MicrovmImage, Stack +from samcli.lib.providers.provider import MicrovmImage, Stack, get_full_path from samcli.lib.providers.sam_base_provider import SamBaseProvider from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider from samcli.lib.utils.architecture import ARM64 @@ -30,6 +30,8 @@ def __init__( ignore_code_extraction_warnings: bool = False, ) -> None: self._microvm_images: List[MicrovmImage] = [] + # Images skipped at extraction: (identifiers it can be looked up by, why it was skipped) + self._skipped_images: List[Tuple[Set[str], str]] = [] self._use_raw_codeuri = use_raw_codeuri self._ignore_code_extraction_warnings = ignore_code_extraction_warnings self._extract_microvm_images(stacks) @@ -54,6 +56,13 @@ def _extract_microvm_images(self, stacks: List[Stack]) -> None: if SamBaseProvider._is_s3_location(code_value): if not self._ignore_code_extraction_warnings: SamBaseProvider._warn_code_extraction(resource_type, logical_id, code_property_key) + self._record_skipped_image( + stack, + logical_id, + properties, + metadata, + f"{code_property_key} is an S3 location; sam build only builds a local source directory", + ) continue # Only extract if code_value is a non-empty local path string @@ -68,6 +77,33 @@ def _extract_microvm_images(self, stacks: List[Stack]) -> None: logical_id, code_property_key, ) + self._record_skipped_image( + stack, logical_id, properties, metadata, f"{code_property_key} is missing or invalid" + ) + + def _record_skipped_image( + self, stack: Stack, logical_id: str, properties: Dict, metadata: Optional[Dict], reason: str + ) -> None: + """Remember why an image was skipped, under the same identifiers get() accepts.""" + sam_resource_id = (metadata or {}).get("SamResourceId") + resource_id = sam_resource_id if isinstance(sam_resource_id, str) and sam_resource_id else logical_id + identifiers = {logical_id, resource_id, get_full_path(stack.stack_path, resource_id)} + name = properties.get("Name") + if isinstance(name, str) and name: + identifiers.add(name) + self._skipped_images.append((identifiers, reason)) + + def get_skip_reason(self, name: str) -> Optional[str]: + """ + Why the MicrovmImage with this identifier was skipped at extraction, or None if it wasn't. + + A skipped image is not returned by get(), so an explicitly targeted build can use this to report + the real reason instead of "resource not found". + """ + for identifiers, reason in self._skipped_images: + if name in identifiers: + return reason + return None def _convert_microvm_image_resource( self, diff --git a/tests/integration/buildcmd/test_build_cmd_microvm_image.py b/tests/integration/buildcmd/test_build_cmd_microvm_image.py index 6bd3822295d..36ba7e2997e 100644 --- a/tests/integration/buildcmd/test_build_cmd_microvm_image.py +++ b/tests/integration/buildcmd/test_build_cmd_microvm_image.py @@ -122,14 +122,14 @@ def test_build_foreach_dynamic_code_uri(self): built_path = Path(self.default_build_dir, mapping[name]["CodeUri"]).resolve() self.assertEqual(built_path, source_dir.joinpath(name).resolve()) - def test_build_fails_when_dockerfile_not_found(self): - """sam build should fail when CodeUri directory doesn't contain a Dockerfile.""" + def test_build_fails_when_code_uri_does_not_exist(self): + """sam build should fail with a specific message when the CodeUri directory doesn't exist.""" overrides = {"CodeUri": "./NonExistentDirectory"} cmdlist = self.get_command_list(parameter_overrides=overrides, use_buildkit=self.use_buildkit) command_result = run_command(cmdlist, cwd=self.working_dir) self.assertEqual(command_result.process.returncode, 1) - self.assertIn("Dockerfile not found", command_result.stderr.decode()) + self.assertIn("does not exist", command_result.stderr.decode()) def tearDown(self): # Clean up the image created during the test (works across Docker and Finch) diff --git a/tests/unit/commands/buildcmd/test_build_context.py b/tests/unit/commands/buildcmd/test_build_context.py index 519c19751b0..1df8e3cf5ee 100644 --- a/tests/unit/commands/buildcmd/test_build_context.py +++ b/tests/unit/commands/buildcmd/test_build_context.py @@ -21,7 +21,7 @@ ) from samcli.lib.build.build_graph import DEFAULT_DEPENDENCIES_DIR from samcli.lib.build.bundler import EsbuildBundlerManager -from samcli.lib.build.exceptions import InvalidBuildGraphException +from samcli.lib.build.exceptions import InvalidBuildGraphException, MicrovmImageSourceError from samcli.lib.build.workflow_config import UnsupportedBuilderException, UnsupportedRuntimeException from samcli.lib.observability.util import OutputOption from samcli.lib.providers.provider import Function, MicrovmImage, ResourcesToBuildCollector, get_function_build_info @@ -3114,6 +3114,31 @@ def test_collect_returns_when_image_not_found(self, mock_provider_cls): self.assertEqual(len(collector.microvm_images), 0) + def test_collect_reports_why_a_skipped_image_is_not_buildable(self): + ctx = self._make_context() + mock_provider = Mock() + mock_provider.get.return_value = None + mock_provider.get_skip_reason.return_value = "CodeUri is an S3 location; sam build only builds a local source" + ctx._microvm_image_provider = mock_provider + + with self.assertRaisesRegex( + MicrovmImageSourceError, "MicrovmImage 'MyImage' is not buildable: CodeUri is an S3" + ): + ctx._collect_single_microvm_image("MyImage", ResourcesToBuildCollector()) + + def test_collect_does_not_report_skipped_image_when_a_function_matched(self): + ctx = self._make_context() + mock_provider = Mock() + mock_provider.get.return_value = None + mock_provider.get_skip_reason.return_value = "CodeUri is an S3 location" + ctx._microvm_image_provider = mock_provider + collector = ResourcesToBuildCollector() + collector.add_function(Mock()) + + ctx._collect_single_microvm_image("MyImage", collector) + + self.assertEqual(len(collector.microvm_images), 0) + class TestCollectAllMicrovmImages(TestCase): """Tests for collect_all_build_resources MicrovmImage filtering.""" diff --git a/tests/unit/commands/local/lib/test_sam_microvm_image_provider.py b/tests/unit/commands/local/lib/test_sam_microvm_image_provider.py index 0c4b0037097..dea9c42e6a1 100644 --- a/tests/unit/commands/local/lib/test_sam_microvm_image_provider.py +++ b/tests/unit/commands/local/lib/test_sam_microvm_image_provider.py @@ -218,6 +218,37 @@ def test_get_prefers_logical_id_over_another_images_name(self): self.assertEqual(provider.get("ImageB").code_uri, "./b") self.assertEqual(provider.get("SomethingElse").name, "ImageB") + def test_get_skip_reason_for_skipped_images(self): + stack = Stack( + "", + "", + "template.yaml", + None, + { + "Resources": { + "S3Image": { + "Type": "AWS::Serverless::MicrovmImage", + "Properties": { + "Name": "s3-image", + "CodeUri": "s3://bucket/app.zip", + "BaseImageArn": "arn:aws:lambda:::microvm-image:base", + "BaseImageVersion": "0", + }, + }, + "BadImage": {"Type": "AWS::Lambda::MicrovmImage", "Properties": {"CodeArtifact": {"Uri": 5}}}, + "GoodImage": {"Type": "AWS::Lambda::MicrovmImage", "Properties": {"CodeArtifact": "./app"}}, + } + }, + ) + provider = SamMicrovmImageProvider([stack], use_raw_codeuri=True) + + for identifier in ("S3Image", "s3-image"): + self.assertIsNone(provider.get(identifier)) + self.assertIn("CodeUri is an S3 location", provider.get_skip_reason(identifier)) + self.assertIn("CodeArtifact is missing or invalid", provider.get_skip_reason("BadImage")) + self.assertIsNone(provider.get_skip_reason("GoodImage")) + self.assertIsNone(provider.get_skip_reason("NonExistent")) + def test_ignore_code_extraction_warnings_skips_warning(self): stack = Stack( "", diff --git a/tests/unit/lib/build_module/test_app_builder.py b/tests/unit/lib/build_module/test_app_builder.py index d4a30141c53..36d1d980454 100644 --- a/tests/unit/lib/build_module/test_app_builder.py +++ b/tests/unit/lib/build_module/test_app_builder.py @@ -20,6 +20,7 @@ DockerBuildFailed, DockerfileOutSideOfContext, LambdaBuilderError, + MicrovmImageSourceError, UnsupportedBuilderLibraryVersionError, ) from samcli.lib.build.exceptions import DockerConnectionError @@ -3869,6 +3870,12 @@ def test_metadata_with_working_dir_child_source_dir(self, os_path_mock, pathlib_ class TestApplicationBuilder_build_microvm_image(TestCase): + def setUp(self): + # The CodeUri directory is a directory unless a test says otherwise + is_dir_patcher = patch("samcli.lib.build.app_builder.pathlib.Path.is_dir", return_value=True) + self.mock_is_dir = is_dir_patcher.start() + self.addCleanup(is_dir_patcher.stop) + def _make_builder(self, microvm_images=None, base_dir="/project"): resources = ResourcesToBuildCollector() if microvm_images: @@ -3919,13 +3926,32 @@ def test_build_microvm_image_calls_docker_build_nested_stack(self, mock_exists): call_kwargs = builder._image_build_client.build_image.call_args[1] self.assertEqual(call_kwargs["tag"], "childstacka-mymicrovm:latest") - @patch("samcli.lib.build.app_builder.pathlib.Path.exists", return_value=False) + @patch("samcli.lib.build.app_builder.pathlib.Path.exists", autospec=True) def test_build_microvm_image_raises_when_no_dockerfile(self, mock_exists): - image = MicrovmImage(name="MyMicroVM", code_uri="./app", architecture="arm64", microvm_image_name="MyMicroVM") - builder = self._make_builder(microvm_images=[image]) + # The directory exists, the Dockerfile in it does not + mock_exists.side_effect = lambda path: path.name != "Dockerfile" + builder = self._make_builder() - with self.assertRaisesRegex(DockerBuildFailed, "Dockerfile not found"): + with self.assertRaisesRegex(MicrovmImageSourceError, "Dockerfile not found in CodeUri directory"): builder._build_microvm_image("MyMicroVM", "./app", "arm64") + builder._image_build_client.build_image.assert_not_called() + + @patch("samcli.lib.build.app_builder.pathlib.Path.exists", return_value=False) + def test_build_microvm_image_raises_when_code_uri_does_not_exist(self, mock_exists): + builder = self._make_builder() + + with self.assertRaisesRegex(MicrovmImageSourceError, "does not exist.*--parameter-overrides"): + builder._build_microvm_image("MyMicroVM", "./CodeUriParam", "arm64") + builder._image_build_client.build_image.assert_not_called() + + @patch("samcli.lib.build.app_builder.pathlib.Path.exists", return_value=True) + def test_build_microvm_image_raises_when_code_uri_is_a_file(self, mock_exists): + self.mock_is_dir.return_value = False + builder = self._make_builder() + + with self.assertRaisesRegex(MicrovmImageSourceError, "is a file.*cannot build a MicrovmImage from a .zip"): + builder._build_microvm_image("MyMicroVM", "./app.zip", "arm64") + builder._image_build_client.build_image.assert_not_called() @patch("samcli.lib.build.app_builder.pathlib.Path.exists", return_value=True) def test_tag_is_lowercase(self, mock_exists): From 93f98acf95d47a01a0eacabe624ea53d447304c4 Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Fri, 23 Oct 2026 16:36:13 -0700 Subject: [PATCH 12/23] feat(build): Warn when MicrovmImage Metadata Docker settings are ignored Lambda image functions are built from Metadata Dockerfile, DockerContext, DockerTag and DockerBuildArgs, so users may set them on a MicrovmImage too. They have no effect: a MicrovmImage is built from the Dockerfile at the root of CodeUri, as the MicroVM service builds it. Warn when any of them is set. --- .../providers/sam_microvm_image_provider.py | 13 +++++++++++ .../lib/test_sam_microvm_image_provider.py | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/samcli/lib/providers/sam_microvm_image_provider.py b/samcli/lib/providers/sam_microvm_image_provider.py index 4a4199d90df..c6e01710ba3 100644 --- a/samcli/lib/providers/sam_microvm_image_provider.py +++ b/samcli/lib/providers/sam_microvm_image_provider.py @@ -19,6 +19,10 @@ } _DEFAULT_ARCHITECTURE = ARM64 +# Metadata keys that configure a Lambda image function build. A MicrovmImage is always built from the +# Dockerfile at the root of CodeUri, as the MicroVM service builds it, so these have no effect. +_IGNORED_DOCKER_METADATA_KEYS = ("Dockerfile", "DockerContext", "DockerTag", "DockerBuildArgs") + class SamMicrovmImageProvider(SamBaseProvider): """Extracts MicrovmImage resources from SAM templates.""" @@ -160,6 +164,15 @@ def _convert_microvm_image_resource( cfn_architecture, ) + ignored_metadata_keys = [key for key in _IGNORED_DOCKER_METADATA_KEYS if key in (metadata or {})] + if ignored_metadata_keys: + LOG.warning( + "MicrovmImage '%s' ignores Metadata %s: it is built from the Dockerfile at the root of CodeUri, " + "as the MicroVM service builds it.", + logical_id, + ", ".join(ignored_metadata_keys), + ) + return MicrovmImage( name=logical_id, microvm_image_name=resource_properties.get("Name"), diff --git a/tests/unit/commands/local/lib/test_sam_microvm_image_provider.py b/tests/unit/commands/local/lib/test_sam_microvm_image_provider.py index dea9c42e6a1..bd615555fd8 100644 --- a/tests/unit/commands/local/lib/test_sam_microvm_image_provider.py +++ b/tests/unit/commands/local/lib/test_sam_microvm_image_provider.py @@ -249,6 +249,28 @@ def test_get_skip_reason_for_skipped_images(self): self.assertIsNone(provider.get_skip_reason("GoodImage")) self.assertIsNone(provider.get_skip_reason("NonExistent")) + @parameterized.expand( + [ + ("docker_metadata", {"Dockerfile": "Dockerfile.dev", "DockerBuildArgs": {"A": "1"}}, True), + ("no_docker_metadata", {"SkipBuild": False}, False), + ("no_metadata", None, False), + ] + ) + @patch("samcli.lib.providers.sam_microvm_image_provider.LOG") + def test_warns_on_ignored_docker_metadata(self, _name, metadata, expect_warning, mock_log): + resource = {"Type": "AWS::Lambda::MicrovmImage", "Properties": {"CodeArtifact": "./app"}} + if metadata is not None: + resource["Metadata"] = metadata + stack = Stack("", "", "template.yaml", None, {"Resources": {"MyImage": resource}}) + + SamMicrovmImageProvider([stack], use_raw_codeuri=True) + + warnings = [c.args for c in mock_log.warning.call_args_list if "ignores Metadata" in c.args[0]] + if expect_warning: + self.assertEqual(warnings, [(warnings[0][0], "MyImage", "Dockerfile, DockerBuildArgs")]) + else: + self.assertEqual(warnings, []) + def test_ignore_code_extraction_warnings_skips_warning(self): stack = Stack( "", From 749383c1580a5f0726c54218c9a318575199c711 Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Fri, 23 Oct 2026 16:45:47 -0700 Subject: [PATCH 13/23] fix(build): Print one progress line per MicrovmImage build Both the build strategy and _build_microvm_image logged a "Building MicrovmImage" line for the same image. Keep the strategy's line, which includes the source directory and is where function and layer progress lines are logged. --- samcli/lib/build/app_builder.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index 3ebc8b578ed..da684b02dad 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -1086,8 +1086,6 @@ def _build_microvm_image(self, full_path: str, code_uri: str, architecture: str) str The Docker image tag that was built """ - LOG.info("Building MicrovmImage %s", full_path) - docker_context_dir = pathlib.Path(self._base_dir, code_uri).resolve() dockerfile_path = docker_context_dir / "Dockerfile" From 67b32bfadebe48358869fabc3a47eaa7d8dcf24b Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Mon, 26 Oct 2026 13:16:27 -0700 Subject: [PATCH 14/23] fix(build): Collect build resources once so skip warnings print once run() collected the resources to build, and then _check_build_method_experimental_flag collected them again. Collecting logs a warning for each skipped MicrovmImage, so each warning printed twice. Pass the already collected resources to the check instead. --- samcli/commands/build/build_context.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/samcli/commands/build/build_context.py b/samcli/commands/build/build_context.py index 7bf5cca7dc7..3cc526188ff 100644 --- a/samcli/commands/build/build_context.py +++ b/samcli/commands/build/build_context.py @@ -360,7 +360,7 @@ def run(self) -> None: ) self._check_exclude_warning() - self._check_build_method_experimental_flag() + self._check_build_method_experimental_flag(resources_to_build) for f in resources_to_build.functions: EventTracker.track_event(EventName.BUILD_FUNCTION_RUNTIME.value, f.runtime) @@ -1687,15 +1687,24 @@ def _check_exclude_warning(self) -> None: if self._resource_identifier in excludes: LOG.warning(self._EXCLUDE_WARNING_MESSAGE) - def _check_build_method_experimental_flag(self) -> None: + def _check_build_method_experimental_flag( + self, resources_to_build: Optional[ResourcesToBuildCollector] = None + ) -> None: """ Prints warning message and confirms if user wants to use beta feature + + Parameters + ---------- + resources_to_build: Optional[ResourcesToBuildCollector] + The resources already collected for this build. Passing them avoids collecting again, which + would repeat the collection's warnings (such as skipped MicrovmImages). """ EXPERIMENTAL_BUILD_METHODS = { "python-uv": ExperimentalFlag.UvPackageManager, } - resources_to_build = self.get_resources_to_build() + if resources_to_build is None: + resources_to_build = self.get_resources_to_build() 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", "") From 9ea5c657874d8a65bc0a367cef5707ef4531f8bf Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Mon, 26 Oct 2026 13:28:47 -0700 Subject: [PATCH 15/23] fix(build): Only suggest --parameter-overrides for an unset Architecture parameter The unsupported Architecture warning always suggested --parameter-overrides, including for a literal such as X86_64. An unresolvable !Ref is rewritten to the parameter name, so add the hint only when the value is the name of a template parameter, and name that parameter. --- .../providers/sam_microvm_image_provider.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/samcli/lib/providers/sam_microvm_image_provider.py b/samcli/lib/providers/sam_microvm_image_provider.py index c6e01710ba3..f2da24df618 100644 --- a/samcli/lib/providers/sam_microvm_image_provider.py +++ b/samcli/lib/providers/sam_microvm_image_provider.py @@ -150,19 +150,19 @@ def _convert_microvm_image_resource( first_config = cpu_configurations[0] if isinstance(first_config, dict) and "Architecture" in first_config: cfn_architecture = first_config.get("Architecture") - # An unresolvable !Ref is rewritten by IntrinsicResolver to the parameter name - # (a string), so it lands here as an unsupported value rather than a dict. architecture = ( _SUPPORTED_ARCHITECTURES.get(cfn_architecture) if isinstance(cfn_architecture, str) else None ) if architecture is None: - LOG.warning( - "MicrovmImage '%s' has unsupported Architecture %r; only ARM_64 is supported. " - "If Architecture references a template parameter, provide its value via " - "--parameter-overrides.", - logical_id, - cfn_architecture, - ) + message = "MicrovmImage '%s' has unsupported Architecture %r; only ARM_64 is supported." + args = [logical_id, cfn_architecture] + # An unresolvable !Ref is rewritten by IntrinsicResolver to the parameter name, so a value + # equal to a parameter name means the parameter has no value + template_parameters = (stack.template_dict or {}).get("Parameters") or {} + if isinstance(cfn_architecture, str) and cfn_architecture in template_parameters: + message += " %r is a template parameter with no value; provide it via --parameter-overrides." + args.append(cfn_architecture) + LOG.warning(message, *args) ignored_metadata_keys = [key for key in _IGNORED_DOCKER_METADATA_KEYS if key in (metadata or {})] if ignored_metadata_keys: From 05e9ed3be19f09f2e55623170242859e08ecf66d Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Mon, 26 Oct 2026 13:39:23 -0700 Subject: [PATCH 16/23] fix(build): Mention MicroVM images in build help and --cached output With --cached, functions and layers report whether their cache was valid, but MicrovmImages, which are always rebuilt with Docker's layer cache, printed nothing. Log that SAM CLI does not cache them. The build description and the --parallel help still described only functions and layers. Include MicroVM images, and regenerate the schema. --- samcli/commands/build/command.py | 7 +++++-- samcli/lib/build/build_strategy.py | 5 +++++ schema/samcli.json | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/samcli/commands/build/command.py b/samcli/commands/build/command.py index c4a6954c0b2..fea974c4d5b 100644 --- a/samcli/commands/build/command.py +++ b/samcli/commands/build/command.py @@ -48,7 +48,7 @@ DESCRIPTION = """ Build AWS serverless function code to generate artifacts targeting - AWS Lambda execution environment.\n + AWS Lambda execution environment, and build MicroVM images locally.\n \b Supported Resource Types ------------------------ @@ -111,7 +111,10 @@ help="Name of the resource(s) to exclude from AWS SAM CLI build.", ) @click.option( - "--parallel", "-p", is_flag=True, help="Enable parallel builds for AWS SAM template's functions and layers." + "--parallel", + "-p", + is_flag=True, + help="Enable parallel builds for AWS SAM template's functions, layers and MicroVM images.", ) @click.option( "--mount-with", diff --git a/samcli/lib/build/build_strategy.py b/samcli/lib/build/build_strategy.py index d3e13b3fefb..d01982d2e82 100644 --- a/samcli/lib/build/build_strategy.py +++ b/samcli/lib/build/build_strategy.py @@ -495,6 +495,11 @@ def build_single_microvm_image_definition(self, microvm_definition: MicrovmImage Builds single MicrovmImage definition. Delegates to the underlying build strategy since Docker has its own layer caching. """ + if microvm_definition.microvm_image: + LOG.info( + "MicrovmImage %s is not cached by SAM CLI; Docker's layer cache is used", + microvm_definition.microvm_image.full_path, + ) return self._delegate_build_strategy.build_single_microvm_image_definition(microvm_definition) diff --git a/schema/samcli.json b/schema/samcli.json index a4912dcdab2..9aac2bd3377 100644 --- a/schema/samcli.json +++ b/schema/samcli.json @@ -310,7 +310,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* 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.", + "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, layers and MicroVM images.\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": { @@ -371,7 +371,7 @@ "parallel": { "title": "parallel", "type": "boolean", - "description": "Enable parallel builds for AWS SAM template's functions and layers." + "description": "Enable parallel builds for AWS SAM template's functions, layers and MicroVM images." }, "mount_with": { "title": "mount_with", From 0fc2c67b74563a9a42886c77a3396290469e9cab Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Tue, 27 Oct 2026 09:38:09 -0700 Subject: [PATCH 17/23] fix(build): Skip MicrovmImages without a container runtime on build-all A plain sam build on a template with a MicrovmImage failed when no container runtime was available, although the local image is only for local testing and packaging uses the source directory. On the build-all path, warn and skip the images instead. Building a targeted image still fails. --- samcli/commands/build/build_context.py | 22 ++++++++++++++++++- .../commands/buildcmd/test_build_context.py | 5 ++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/samcli/commands/build/build_context.py b/samcli/commands/build/build_context.py index 3cc526188ff..bfe63aebb86 100644 --- a/samcli/commands/build/build_context.py +++ b/samcli/commands/build/build_context.py @@ -78,8 +78,9 @@ from samcli.lib.telemetry.event import EventName, EventTracker, UsedFeature from samcli.lib.utils.osutils import BUILD_DIR_PERMISSIONS from samcli.lib.utils.path_utils import is_same_or_ancestor +from samcli.local.docker.exceptions import ContainerEnforcementException, ContainerNotReachableException from samcli.local.docker.manager import ContainerManager -from samcli.local.docker.utils import get_docker_platform +from samcli.local.docker.utils import get_docker_platform, get_validated_container_client from samcli.local.lambdafn.exceptions import ( FunctionNotFound, ResourceNotFound, @@ -1554,9 +1555,28 @@ def collect_all_build_resources(self) -> ResourcesToBuildCollector: LOG.warning("Skipping MicrovmImage %s: %s", image.full_path, non_buildable_reason) continue buildable_images.append(image) + # The local image is only for local testing; packaging uses the source directory. So without a + # container runtime, skip the images rather than fail a build whose deployment does not need them. + # Building a targeted image still fails. + if buildable_images and not BuildContext._is_container_runtime_available(): + for image in buildable_images: + LOG.warning( + "Skipping MicrovmImage %s: no container runtime (Docker or Finch) is available. The local " + "image is only used for local testing; packaging uses the source directory.", + image.full_path, + ) + buildable_images = [] result.add_microvm_images(buildable_images) return result + @staticmethod + def _is_container_runtime_available() -> bool: + try: + get_validated_container_client() + except (ContainerNotReachableException, ContainerEnforcementException): + return False + return True + @property def is_building_specific_resource(self) -> bool: """ diff --git a/tests/unit/commands/buildcmd/test_build_context.py b/tests/unit/commands/buildcmd/test_build_context.py index 1df8e3cf5ee..5b3e9ab835b 100644 --- a/tests/unit/commands/buildcmd/test_build_context.py +++ b/tests/unit/commands/buildcmd/test_build_context.py @@ -3143,10 +3143,13 @@ def test_collect_does_not_report_skipped_image_when_a_function_matched(self): class TestCollectAllMicrovmImages(TestCase): """Tests for collect_all_build_resources MicrovmImage filtering.""" + @patch("samcli.commands.build.build_context.BuildContext._is_container_runtime_available", return_value=True) @patch("samcli.commands.build.build_context.SamMicrovmImageProvider") @patch("samcli.commands.build.build_context.SamLayerProvider") @patch("samcli.commands.build.build_context.SamFunctionProvider") - def test_collect_all_skips_unbuildable_with_warning(self, mock_func_prov, mock_layer_prov, mock_mvm_prov): + def test_collect_all_skips_unbuildable_with_warning( + self, mock_func_prov, mock_layer_prov, mock_mvm_prov, mock_runtime_available + ): from samcli.lib.providers.provider import MicrovmImage buildable_image = MicrovmImage(name="Good", code_uri="./app", architecture="arm64") From e28fcc0fc8fd146d0db76c0d7b8b3e9b0debf295 Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Tue, 27 Oct 2026 09:46:34 -0700 Subject: [PATCH 18/23] feat(build): Warn when MicrovmImages map to the same local tag The local tag is derived from the full path, so IDs that differ only in case or punctuation map to the same tag, and the image built last overwrites the others. Warn about such collisions within a build. --- samcli/lib/build/app_builder.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index da684b02dad..68b67e86643 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -239,6 +239,7 @@ def build(self) -> ApplicationBuildResult: to the path string """ build_graph = self._get_build_graph(self._container_env_var, self._container_env_var_file) + self._warn_on_microvm_image_tag_collisions() # Add cross-architecture warning when using containers if self._container_manager: @@ -1049,6 +1050,23 @@ def _get_working_directory_path( return working_directory + def _warn_on_microvm_image_tag_collisions(self) -> None: + """ + Warn when MicrovmImages in this build map to the same local tag, so the image built last overwrites the + others. Tags are derived from the full path, so for example IDs that differ only in case collide. + """ + full_paths_by_tag: Dict[str, List[str]] = {} + for image in self._resources_to_build.microvm_images: + full_paths_by_tag.setdefault(self._microvm_image_tag(image.full_path), []).append(image.full_path) + for tag, full_paths in full_paths_by_tag.items(): + if len(full_paths) > 1: + LOG.warning( + "MicrovmImages %s all map to the local image tag '%s', so the image built last overwrites " + "the others. Rename the resources so their IDs differ by more than case or punctuation.", + ", ".join(full_paths), + tag, + ) + @staticmethod def _microvm_image_tag(full_path: str) -> str: """ From 789e74aee96d9102e02306090e7702d0320f6010 Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Tue, 27 Oct 2026 09:53:13 -0700 Subject: [PATCH 19/23] refactor(build): Share the image build client setup _build_lambda_image and _build_microvm_image each had the same lazy setup that picks the BuildKit CLI client or the Docker SDK client. Move it into _get_image_build_client so the two paths cannot drift. --- samcli/lib/build/app_builder.py | 58 +++++++++++++++------------------ 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index 68b67e86643..0877988702a 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -502,6 +502,31 @@ def _is_web_function(self, function_name: str) -> bool: return False return any(isinstance(function, WebFunction) and function.name == function_name for function in functions) + def _get_image_build_client(self) -> ImageBuildClient: + """ + Lazily create the client used for image builds, shared by Lambda image functions and MicrovmImages. + Uses the CLI client (BuildKit) with --use-buildkit, otherwise the Docker SDK client. + + Raises + ------ + BuildkitNotAvailableException + When --use-buildkit is set but BuildKit is not available for the container runtime + """ + if not self._image_build_client: + if self._use_buildkit: + engine_type = self._container_client.get_runtime_type() + + is_available, error_msg = CLIBuildClient.is_available(engine_type) + if not is_available: + raise BuildkitNotAvailableException(error_msg) + + self._image_build_client = CLIBuildClient(engine_type=engine_type) + LOG.debug(f"Using CLIBuildClient with engine_type {engine_type}") + else: + self._image_build_client = SDKBuildClient(self._container_client) + LOG.debug("Using SDKBuildClient") + return self._image_build_client + def _build_lambda_image(self, function_name: str, metadata: Dict, architecture: str) -> str: """ Build an Lambda image @@ -568,21 +593,7 @@ def _build_lambda_image(self, function_name: str, metadata: Dict, architecture: build_args["extra_params"] = docker_build_extra_params try: - if not self._image_build_client: - if self._use_buildkit: - container_client = self._container_client - engine_type = container_client.get_runtime_type() - - is_available, error_msg = CLIBuildClient.is_available(engine_type) - if not is_available: - raise BuildkitNotAvailableException(error_msg) - - self._image_build_client = CLIBuildClient(engine_type=engine_type) - LOG.debug(f"Using CLIBuildClient with engine_type {engine_type}") - else: - self._image_build_client = SDKBuildClient(self._container_client) - LOG.debug("Using SDKBuildClient") - build_logs = self._image_build_client.build_image(**build_args) # type: ignore[arg-type] + build_logs = self._get_image_build_client().build_image(**build_args) # type: ignore[arg-type] LOG.debug(f"Image built for {function_name} function") except docker.errors.BuildError as ex: LOG.error("Failed building function %s", function_name) @@ -1148,22 +1159,7 @@ def _build_microvm_image(self, full_path: str, code_uri: str, architecture: str) } try: - if not self._image_build_client: - if self._use_buildkit: - container_client = self._container_client - engine_type = container_client.get_runtime_type() - - is_available, error_msg = CLIBuildClient.is_available(engine_type) - if not is_available: - raise BuildkitNotAvailableException(error_msg) - - self._image_build_client = CLIBuildClient(engine_type=engine_type) - LOG.debug(f"Using CLIBuildClient with engine_type {engine_type}") - else: - self._image_build_client = SDKBuildClient(self._container_client) - LOG.debug("Using SDKBuildClient") - - build_logs = self._image_build_client.build_image(**build_args) # type: ignore[arg-type] + build_logs = self._get_image_build_client().build_image(**build_args) # type: ignore[arg-type] LOG.debug("MicrovmImage built for %s", full_path) except docker.errors.BuildError as ex: LOG.error("Failed building MicrovmImage %s", full_path) From d9fe65df99268d26eff0dc6ec0120ccbd8d32413 Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Tue, 27 Oct 2026 09:59:23 -0700 Subject: [PATCH 20/23] fix(build): Address MicrovmImage consistency review comments - Return ApplicationBuildResult directly, without a temporary variable. - Add a return type to is_microvm_image_buildable, which returns the skip reason with the result, unlike is_layer_buildable. - Treat a CodeUri ending in .ZIP or other casing as a .zip file. - Track a UsedFeature telemetry event when a build includes MicrovmImages. --- samcli/commands/build/build_context.py | 9 +++++++-- samcli/lib/build/app_builder.py | 4 +--- samcli/lib/telemetry/event.py | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/samcli/commands/build/build_context.py b/samcli/commands/build/build_context.py index bfe63aebb86..717992fdf18 100644 --- a/samcli/commands/build/build_context.py +++ b/samcli/commands/build/build_context.py @@ -365,6 +365,8 @@ def run(self) -> None: for f in resources_to_build.functions: EventTracker.track_event(EventName.BUILD_FUNCTION_RUNTIME.value, f.runtime) + if resources_to_build.microvm_images: + EventTracker.track_event(EventName.USED_FEATURE.value, UsedFeature.MICROVM_IMAGE_BUILD.value) self._build_result = builder.build() @@ -1688,8 +1690,11 @@ def is_layer_buildable(layer: LayerVersion): return True @staticmethod - def is_microvm_image_buildable(image: MicrovmImage): - if isinstance(image.code_uri, str) and image.code_uri.endswith(".zip"): + def is_microvm_image_buildable(image: MicrovmImage) -> Tuple[bool, str]: + """ + Returns whether the image is buildable, and the reason it is not (empty when it is) + """ + if isinstance(image.code_uri, str) and image.code_uri.lower().endswith(".zip"): return False, "CodeUri points to a .zip file, not a buildable source directory." if image.metadata and image.metadata.get("SkipBuild"): return False, "Metadata.SkipBuild is set." diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index 0877988702a..0f30885dd50 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -285,9 +285,7 @@ def build(self) -> ApplicationBuildResult: bool(self._container_manager), ) - result = ApplicationBuildResult(build_graph, build_strategy.build()) - - return result + return ApplicationBuildResult(build_graph, build_strategy.build()) def _get_build_graph( self, inline_env_vars: Optional[Dict] = None, env_vars_file: Optional[str] = None diff --git a/samcli/lib/telemetry/event.py b/samcli/lib/telemetry/event.py index ff4a723d283..8702d25538e 100644 --- a/samcli/lib/telemetry/event.py +++ b/samcli/lib/telemetry/event.py @@ -43,6 +43,7 @@ class UsedFeature(Enum): INVOKED_CUSTOM_LAMBDA_AUTHORIZERS = "InvokedLambdaAuthorizers" BUILD_IN_SOURCE = "BuildInSource" CFN_LANGUAGE_EXTENSIONS = "CFNLanguageExtensions" + MICROVM_IMAGE_BUILD = "MicrovmImageBuild" class EventType: From e695d6cf71d00fafe6a01127af3deb0ac8d899c4 Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Wed, 28 Oct 2026 13:29:20 -0700 Subject: [PATCH 21/23] fix(build): Forward MicrovmImage builds in WorkspaceAwareBuildStrategy WorkspaceAwareBuildStrategy, added on develop in #9352, did not implement the abstract build_single_microvm_image_definition, so it could not be created and every sam build with the NodejsMonorepo flag and --build-in-source failed. Forward MicrovmImage builds to the delegate, like layers and functions, so a wrapped parallel strategy still schedules them. --- samcli/lib/build/build_strategy.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/samcli/lib/build/build_strategy.py b/samcli/lib/build/build_strategy.py index 828f362381c..7b262c57dec 100644 --- a/samcli/lib/build/build_strategy.py +++ b/samcli/lib/build/build_strategy.py @@ -834,6 +834,12 @@ def build_single_function_definition(self, build_definition: FunctionBuildDefini def build_single_layer_definition(self, layer_definition: LayerBuildDefinition) -> Dict[str, str]: return self._delegate_build_strategy.build_single_layer_definition(layer_definition) + def _build_microvm_images(self, build_graph: BuildGraph) -> Dict[str, str]: + return self._delegate_build_strategy._build_microvm_images(build_graph) + + def build_single_microvm_image_definition(self, microvm_definition: MicrovmImageBuildDefinition) -> Dict[str, str]: + return self._delegate_build_strategy.build_single_microvm_image_definition(microvm_definition) + def _prepare_shared_project_for_workspace_groups(self, build_graph: BuildGraph) -> None: groups: Dict[str, List[FunctionBuildDefinition]] = {} for build_definition in build_graph.get_function_build_definitions(): From d83185dbe7f72857a4ff4b14d4ad12147ba3769c Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Wed, 28 Oct 2026 13:29:21 -0700 Subject: [PATCH 22/23] style: Restore import grouping in app_builder.py The develop merge dropped the blank line between the third-party and samcli imports, which ruff reports as I001. --- samcli/lib/build/app_builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index 547bf0be51b..0067a057891 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -18,6 +18,7 @@ ) from aws_lambda_builders.builder import LambdaBuilder from aws_lambda_builders.exceptions import LambdaBuilderError + from samcli.commands._utils.experimental import ( ExperimentalFlag, get_enabled_experimental_flags, From 767e65941145bb5a4c2960b662a5bf349d7928d9 Mon Sep 17 00:00:00 2001 From: Chengjun Li Date: Wed, 28 Oct 2026 21:12:12 -0700 Subject: [PATCH 23/23] chore: Regenerate schema after the develop merge The develop merge kept develop's schema/samcli.json, which dropped the MicroVM images wording from the --parallel help. --- schema/samcli.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/samcli.json b/schema/samcli.json index 0b684082b20..94a801219da 100644 --- a/schema/samcli.json +++ b/schema/samcli.json @@ -310,7 +310,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\nFor `sam build` on an npm workspaces monorepo, npm installs once at the workspace root instead of once per function, and each function's artifacts are narrowed to the dependencies it resolves. The install runs in the workspace root npm itself reports, which is inside your project but above the function's CodeUri.\n\nThis grouping is in beta: enable it with --beta-features or SAM_CLI_BETA_NODEJS_MONOREPO=1. Without it, every function installs for itself, as in earlier releases. It is also skipped for --use-container and --cached builds, so `sam sync` does not use it.\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.", + "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\nFor `sam build` on an npm workspaces monorepo, npm installs once at the workspace root instead of once per function, and each function's artifacts are narrowed to the dependencies it resolves. The install runs in the workspace root npm itself reports, which is inside your project but above the function's CodeUri.\n\nThis grouping is in beta: enable it with --beta-features or SAM_CLI_BETA_NODEJS_MONOREPO=1. Without it, every function installs for itself, as in earlier releases. It is also skipped for --use-container and --cached builds, so `sam sync` does not use it.\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, layers and MicroVM images.\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": {