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/samcli/commands/build/build_context.py b/samcli/commands/build/build_context.py index ab3da0a3a87..c20c7070551 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 @@ -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 @@ -62,6 +63,7 @@ from samcli.lib.providers.provider import ( BuildableFunction, LayerVersion, + MicrovmImage, ResourcesToBuildCollector, Stack, WebFunction, @@ -70,12 +72,15 @@ 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 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, get_validated_container_client from samcli.local.lambdafn.exceptions import ( FunctionNotFound, ResourceNotFound, @@ -225,6 +230,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 +273,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: @@ -354,10 +361,12 @@ 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) + if resources_to_build.microvm_images: + EventTracker.track_event(EventName.USED_FEATURE.value, UsedFeature.MICROVM_IMAGE_BUILD.value) self._build_result = builder.build() @@ -1142,6 +1151,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 @@ -1175,18 +1187,31 @@ 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]] = [ - 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, + # 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 + ] + ) click.echo( json.dumps( { @@ -1214,6 +1239,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") @@ -1228,7 +1254,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 @@ -1240,6 +1272,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 ------- @@ -1257,7 +1291,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: @@ -1270,7 +1315,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 ========================= """ @@ -1345,6 +1402,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 +1455,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 +1499,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,8 +1548,37 @@ 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) + # 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: """ @@ -1547,6 +1642,37 @@ 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: + # 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: + 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 +1689,19 @@ def is_layer_buildable(layer: LayerVersion): return False return True + @staticmethod + 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." + 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: @@ -1573,15 +1712,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", "") diff --git a/samcli/commands/build/command.py b/samcli/commands/build/command.py index 2fe020aebca..fea974c4d5b 100644 --- a/samcli/commands/build/command.py +++ b/samcli/commands/build/command.py @@ -43,12 +43,12 @@ LOG = logging.getLogger(__name__) HELP_TEXT = """ - Build AWS serverless function code. + Build AWS serverless function code and MicroVM images. """ 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 ------------------------ @@ -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 ------------------ @@ -109,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/app_builder.py b/samcli/lib/build/app_builder.py index ae53505aa62..0067a057891 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 @@ -23,7 +24,12 @@ get_enabled_experimental_flags, is_experimental_enabled, ) -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, @@ -37,9 +43,10 @@ BuildInsideContainerError, DockerBuildFailed, DockerfileOutSideOfContext, + MicrovmImageSourceError, 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, @@ -65,9 +72,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 @@ -214,6 +223,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] = {} # one SubprocessNpm per build session, so `npm prefix` answers are cached per directory self._npm_subprocess: Optional[object] = None @@ -238,6 +250,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: @@ -246,7 +259,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: @@ -311,6 +329,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: @@ -352,11 +371,19 @@ 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 - @staticmethod def update_template( + self, stack: Stack, built_artifacts: Dict[str, str], stack_output_template_path_by_stack_path: Dict[str, str], @@ -419,6 +446,9 @@ def update_template( store_path = os.path.relpath(absolute_output_path, original_dir) if has_build_artifact: + if resource_type in (AWS_SERVERLESS_MICROVMIMAGE, AWS_LAMBDA_MICROVMIMAGE): + 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 ) @@ -432,8 +462,53 @@ 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: 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: resource_properties["CodeUri"] = absolute_path if resource_type == AWS_SERVERLESS_WEBFUNCTION: @@ -456,6 +531,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 @@ -522,21 +622,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) @@ -1066,6 +1152,125 @@ 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: + """ + 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 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. + + 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 + """ + 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 MicrovmImageSourceError( + 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 = self._microvm_image_tag(full_path) + + build_args = { + "path": str(docker_context_dir), + "dockerfile": "Dockerfile", + "tag": docker_tag, + "buildargs": {}, + "platform": get_docker_platform(architecture), + "rm": True, + } + + try: + 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) + 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 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 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 + 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 318185ea3cf..7b262c57dec 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,18 @@ 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. + """ + 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) + class ParallelBuildStrategy(BuildStrategy): """ @@ -472,10 +530,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 +560,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 +608,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 +734,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 @@ -762,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(): diff --git a/samcli/lib/build/exceptions.py b/samcli/lib/build/exceptions.py index 961a3eb26a9..2c85aac7983 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/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/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..f2da24df618 --- /dev/null +++ b/samcli/lib/providers/sam_microvm_image_provider.py @@ -0,0 +1,198 @@ +"""Provider for AWS::Serverless::MicrovmImage resources.""" + +import logging +from typing import Dict, List, Optional, Set, Tuple + +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 +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 + +# 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.""" + + def __init__( + self, + stacks: List[Stack], + use_raw_codeuri: bool = False, + 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) + + 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) + 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 + 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, + ) + 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, + 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") + architecture = ( + _SUPPORTED_ARCHITECTURES.get(cfn_architecture) if isinstance(cfn_architecture, str) else None + ) + if architecture is None: + 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: + 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"), + 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/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: 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 0288ffa3f36..94a801219da 100644 --- a/schema/samcli.json +++ b/schema/samcli.json @@ -306,11 +306,11 @@ }, "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", - "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": { @@ -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", 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..36ba7e2997e --- /dev/null +++ b/tests/integration/buildcmd/test_build_cmd_microvm_image.py @@ -0,0 +1,142 @@ +"""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_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_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_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("does not exist", 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() + 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/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/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)" 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" 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..5b3e9ab835b 100644 --- a/tests/unit/commands/buildcmd/test_build_context.py +++ b/tests/unit/commands/buildcmd/test_build_context.py @@ -21,10 +21,10 @@ ) 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, 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 @@ -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") @@ -1097,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" @@ -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,12 +1377,15 @@ 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=[]) + resources_mock.return_value = Mock(functions=[], layers=[], microvm_images=[]) builder_mock = ApplicationBuilderMock.return_value = Mock() artifacts = builder_mock.build.return_value = "artifacts" @@ -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() @@ -1679,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" @@ -1733,6 +1861,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): @@ -2865,3 +3017,167 @@ 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) + + 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.""" + + @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, mock_runtime_available + ): + 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..bd615555fd8 --- /dev/null +++ b/tests/unit/commands/local/lib/test_sam_microvm_image_provider.py @@ -0,0 +1,319 @@ +"""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_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")) + + @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( + "", + "", + "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 89d484d0357..da9fa4c8a7f 100644 --- a/tests/unit/lib/build_module/test_app_builder.py +++ b/tests/unit/lib/build_module/test_app_builder.py @@ -21,11 +21,12 @@ DockerBuildFailed, DockerfileOutSideOfContext, LambdaBuilderError, + MicrovmImageSourceError, UnsupportedBuilderLibraryVersionError, ) 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 @@ -1929,6 +1930,72 @@ def test_updates_web_function_code_uri(self): self.assertEqual(properties["CodeUri"], "build/WebFn") +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"), + ( + "lambda_code_artifact_uri", + "AWS::Lambda::MicrovmImage", + "CodeArtifact", + {"Uri": {"Ref": "Src"}}, + {"Uri": "app"}, + ), + ] + ) + def test_intrinsic_code_path_is_replaced_with_built_directory( + self, _name, resource_type, code_prop, raw_value, expected + ): + 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={}, location=os.path.join(self.proj, "t.yaml") + ) + actual = self.builder.update_template(stack, {"MyImage": "myimage:latest"}, {}) + self.assertEqual(actual["Resources"]["MyImage"]["Properties"]["CodeUri"], "app") + + class TestApplicationBuilder_update_template_windows(TestCase): @patch("samcli.lib.build.app_builder.get_validated_container_client") def setUp(self, mock_get_validated_container_client): @@ -4074,3 +4141,171 @@ 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 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: + 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() + builder._microvm_image_source_dirs = {} + 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") + 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): + # 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", autospec=True) + def test_build_microvm_image_raises_when_no_dockerfile(self, mock_exists): + # 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(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): + builder = self._make_builder() + 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), + ("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): + 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 3f99f97e0fb..f810b2576cd 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, @@ -79,6 +84,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): @@ -201,19 +209,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() @@ -299,7 +332,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() @@ -319,7 +352,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() @@ -383,7 +416,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" @@ -649,7 +682,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, @@ -680,6 +713,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 @@ -712,6 +746,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 @@ -819,6 +854,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, 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()