Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: sam Commands Understand Local File Paths for AWS::Serverless::Function.ImageUri #6930

Merged
merged 12 commits into from
May 1, 2024
Merged
6 changes: 5 additions & 1 deletion samcli/commands/_utils/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,11 @@ def _update_relative_paths(template_dict, original_root, new_root):
resource_type in [AWS_SERVERLESS_FUNCTION, AWS_LAMBDA_FUNCTION]
and properties.get("PackageType", ZIP) == IMAGE
):
continue
if not properties.get("ImageUri"):
continue
resolved_image_archive_path = _resolve_relative_to(properties.get("ImageUri"), original_root, new_root)
if not resolved_image_archive_path or not pathlib.Path(resolved_image_archive_path).is_file():
continue

# SAM GraphQLApi has many instances of CODE_ARTIFACT_PROPERTY and all of them must be updated
if resource_type == AWS_SERVERLESS_GRAPHQLAPI and path_prop_name == graphql_api.CODE_ARTIFACT_PROPERTY:
Expand Down
18 changes: 18 additions & 0 deletions samcli/lib/build/app_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import logging
import os
import pathlib
from pathlib import Path
from typing import Dict, List, NamedTuple, Optional, cast

import docker
Expand Down Expand Up @@ -250,6 +251,7 @@ def _get_build_graph(
function_build_details = FunctionBuildDefinition(
function.runtime,
function.codeuri,
function.imageuri,
function.packagetype,
function.architecture,
function.metadata,
Expand Down Expand Up @@ -460,6 +462,16 @@ def _stream_lambda_image_build_logs(self, build_logs: List[Dict[str, str]], func
except LogStreamError as ex:
raise DockerBuildFailed(msg=f"{function_name} failed to build: {str(ex)}") from ex

def _load_lambda_image(self, image_archive_path: str) -> str:
try:
with open(image_archive_path, mode="rb") as image_archive:
[image, *rest] = self._docker_client.images.load(image_archive)
if len(rest) != 0:
raise DockerBuildFailed("Archive must represent a single image")
return f"{image.id}"
except (docker.errors.APIError, OSError) as ex:
raise DockerBuildFailed(msg=str(ex)) from ex

def _build_layer(
self,
layer_name: str,
Expand Down Expand Up @@ -599,6 +611,7 @@ def _build_function( # pylint: disable=R1710
self,
function_name: str,
codeuri: str,
imageuri: Optional[str],
packagetype: str,
runtime: str,
architecture: str,
Expand All @@ -619,6 +632,9 @@ def _build_function( # pylint: disable=R1710
Name or LogicalId of the function
codeuri : str
Path to where the code lives
imageuri : str
Location of the Lambda Image which is of the form {image}:{tag}, sha256:{digest},
or a path to a local archive
packagetype : str
The package type, 'Zip' or 'Image', see samcli/lib/utils/packagetype.py
runtime : str
Expand Down Expand Up @@ -646,6 +662,8 @@ def _build_function( # pylint: disable=R1710
Path to the location where built artifacts are available
"""
if packagetype == IMAGE:
if imageuri and Path(imageuri).is_file(): # something exists at this path and what exists is a file
return self._load_lambda_image(imageuri) # should be an image archive – load it instead of building it
# pylint: disable=fixme
# FIXME: _build_lambda_image assumes metadata is not None, we need to throw an exception here
return self._build_lambda_image(
Expand Down
4 changes: 4 additions & 0 deletions samcli/lib/build/build_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ def _toml_table_to_function_build_definition(uuid: str, toml_table: tomlkit.api.
function_build_definition = FunctionBuildDefinition(
toml_table.get(RUNTIME_FIELD),
toml_table.get(CODE_URI_FIELD),
None,
chrisoverzero marked this conversation as resolved.
Show resolved Hide resolved
toml_table.get(PACKAGETYPE_FIELD, ZIP),
toml_table.get(ARCHITECTURE_FIELD, X86_64),
dict(toml_table.get(METADATA_FIELD, {})),
Expand Down Expand Up @@ -584,6 +585,7 @@ def __init__(
self,
runtime: Optional[str],
codeuri: Optional[str],
imageuri: Optional[str],
packagetype: str,
architecture: str,
metadata: Optional[Dict],
Expand All @@ -595,6 +597,7 @@ def __init__(
super().__init__(source_hash, manifest_hash, env_vars, architecture)
self.runtime = runtime
self.codeuri = codeuri
self.imageuri = imageuri
self.packagetype = packagetype
self.handler = handler

Expand Down Expand Up @@ -688,6 +691,7 @@ def __eq__(self, other: Any) -> bool:
return (
self.runtime == other.runtime
and self.codeuri == other.codeuri
and self.imageuri == other.imageuri
and self.packagetype == other.packagetype
and self.metadata == other.metadata
and self.env_vars == other.env_vars
Expand Down
5 changes: 4 additions & 1 deletion samcli/lib/build/build_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@ def __init__(
self,
build_graph: BuildGraph,
build_dir: str,
build_function: Callable[[str, str, str, str, str, Optional[str], str, dict, dict, Optional[str], bool], str],
build_function: Callable[
[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],
cached: bool = False,
) -> None:
Expand Down Expand Up @@ -166,6 +168,7 @@ def build_single_function_definition(self, build_definition: FunctionBuildDefini
result = self._build_function(
build_definition.get_function_name(),
build_definition.codeuri, # type: ignore
build_definition.imageuri,
jysheng123 marked this conversation as resolved.
Show resolved Hide resolved
build_definition.packagetype,
build_definition.runtime, # type: ignore
build_definition.architecture,
Expand Down
21 changes: 19 additions & 2 deletions samcli/lib/package/ecr_uploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import base64
import logging
from io import StringIO
from pathlib import Path
from typing import Dict

import botocore
Expand Down Expand Up @@ -76,10 +77,26 @@ def upload(self, image, resource_name):
if not self.login_session_active:
self.login()
self.login_session_active = True

# Sometimes the `resource_name` is used as the `image` parameter to `tag_translation`.
# This is because these two cases (directly from an archive or by ID) are effectively
# anonymous, so the best identifier available in scope is the resource name.
try:
docker_img = self.docker_client.images.get(image)
if Path(image).is_file():
with open(image, mode="rb") as image_archive:
[docker_img, *rest] = self.docker_client.images.load(image_archive)
if len(rest) != 0:
raise DockerPushFailedError("Archive must represent a single image")
_tag = tag_translation(resource_name, docker_image_id=docker_img.id, gen_tag=self.tag)
else:
# If it's not a file, it's gotta be a {repo}:{tag} or a sha256:{digest}
docker_img = self.docker_client.images.get(image)
_tag = tag_translation(
resource_name if image == docker_img.id else image,
docker_image_id=docker_img.id,
gen_tag=self.tag,
)

_tag = tag_translation(image, docker_image_id=docker_img.id, gen_tag=self.tag)
repository = (
self.ecr_repo
if not self.ecr_repo_multi or not isinstance(self.ecr_repo_multi, dict)
Expand Down
4 changes: 4 additions & 0 deletions samcli/lib/package/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ def upload_local_image_artifacts(resource_id, resource_dict, property_name, pare
LOG.debug("Property %s of %s is already an ECR URL", property_name, resource_id)
return image_path

possible_image_archive_path = make_abs_path(parent_dir, image_path)
if is_local_file(possible_image_archive_path):
image_path = possible_image_archive_path

return uploader.upload(image_path, resource_id)


Expand Down
7 changes: 5 additions & 2 deletions samcli/lib/providers/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import posixpath
from collections import namedtuple
from enum import Enum
from pathlib import Path
from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Set, Union, cast

from samcli.commands.local.cli_common.user_exceptions import (
Expand Down Expand Up @@ -953,6 +954,7 @@ def get_function_build_info(
packagetype: str,
inlinecode: Optional[str],
codeuri: Optional[str],
imageuri: Optional[str],
metadata: Optional[Dict],
) -> FunctionBuildInfo:
"""
Expand All @@ -974,8 +976,9 @@ def get_function_build_info(
metadata = metadata or {}
dockerfile = cast(str, metadata.get("Dockerfile", ""))
docker_context = cast(str, metadata.get("DockerContext", ""))

if not dockerfile or not docker_context:
buildable = dockerfile and docker_context
loadable = imageuri and Path(imageuri).is_file()
if not buildable and not loadable:
LOG.debug(
"Skip Building %s function, as it is missing either Dockerfile or DockerContext "
"metadata properties.",
Expand Down
9 changes: 8 additions & 1 deletion samcli/lib/providers/sam_function_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import logging
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, cast

from samtranslator.policy_template_processor.exceptions import TemplateNotFoundException
Expand Down Expand Up @@ -446,12 +447,18 @@ def _build_function_configuration(
LOG.debug("--base-dir is not presented, adjusting uri %s relative to %s", codeuri, stack.location)
codeuri = SamLocalStackProvider.normalize_resource_path(stack.location, codeuri)

if imageuri and codeuri != ".":
normalized_image_uri = SamLocalStackProvider.normalize_resource_path(stack.location, imageuri)
if Path(normalized_image_uri).is_file():
LOG.debug("--base-dir is not presented, adjusting uri %s relative to %s", codeuri, stack.location)
imageuri = normalized_image_uri

package_type = resource_properties.get("PackageType", ZIP)
if package_type == ZIP and not resource_properties.get("Handler"):
raise MissingFunctionHandlerException(f"Could not find handler for function: {name}")

function_build_info = get_function_build_info(
get_full_path(stack.stack_path, function_id), package_type, inlinecode, codeuri, metadata
get_full_path(stack.stack_path, function_id), package_type, inlinecode, codeuri, imageuri, metadata
)

return Function(
Expand Down
6 changes: 4 additions & 2 deletions samcli/local/docker/lambda_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ def __init__(
runtime str
Name of the Lambda runtime
imageuri str
Name of the Lambda Image which is of the form {image}:{tag}
Location of the Lambda Image which is of the form {image}:{tag}, sha256:{digest},
or a path to a local archive
handler str
Handler of the function to run
packagetype str
Expand Down Expand Up @@ -240,7 +241,8 @@ def _get_image(
packagetype : str
Package type for the lambda function which is either zip or image.
image : str
Name of the Lambda Image which is of the form {image}:{tag}
Location of the Lambda Image which is of the form {image}:{tag}, sha256:{digest},
or a path to a local archive
layers : List[str]
List of layers
architecture : str
Expand Down
3 changes: 2 additions & 1 deletion samcli/local/lambdafn/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ def __init__(
handler : str
Handler method
imageuri : str
Name of the Lambda Image which is of the form {image}:{tag}
Location of the Lambda Image which is of the form {image}:{tag}, sha256:{digest},
or a path to a local archive
imageconfig : str
Image configuration which can be used set to entrypoint, command and working dir for the container.
packagetype : str
Expand Down
1 change: 1 addition & 0 deletions tests/integration/buildcmd/build_integ_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ def _verify_image_build_artifact(self, template_path, image_function_logical_id,
def _verify_resource_property(self, template_path, logical_id, property, expected_value):
with open(template_path, "r") as fp:
template_dict = yaml_parse(fp.read())

self.assertEqual(
expected_value, jmespath.search(f"Resources.{logical_id}.Properties.{property}", template_dict)
)
Expand Down
35 changes: 35 additions & 0 deletions tests/integration/buildcmd/test_build_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,41 @@ def test_with_invalid_dockerfile_definition(self):
self.assertIn("COPY requires at least two arguments", command_result.stderr.decode())


@skipIf(SKIP_DOCKER_TESTS, SKIP_DOCKER_MESSAGE)
class TestLoadingImagesFromArchive(BuildIntegBase):
template = "template_loadable_image.yaml"

FUNCTION_LOGICAL_ID = "ImageFunction"

def test_load_not_an_archive_passthrough(self):
overrides = {"ImageUri": "./load_image_archive/this_file_does_not_exist.tar.gz"}
cmdlist = self.get_command_list(parameter_overrides=overrides)
command_result = run_command(cmdlist, cwd=self.working_dir)

self.assertEqual(command_result.process.returncode, 0)

def test_bad_image_archive_fails(self):
overrides = {"ImageUri": "./load_image_archive/error.tar.gz"}
cmdlist = self.get_command_list(parameter_overrides=overrides)
command_result = run_command(cmdlist, cwd=self.working_dir)

self.assertEqual(command_result.process.returncode, 1)
self.assertIn("unexpected EOF", command_result.stderr.decode())

def test_load_success(self):
overrides = {"ImageUri": "./load_image_archive/archive.tar.gz"}
cmdlist = self.get_command_list(parameter_overrides=overrides)
command_result = run_command(cmdlist, cwd=self.working_dir)

self.assertEqual(command_result.process.returncode, 0)
self._verify_image_build_artifact(
self.built_template,
self.FUNCTION_LOGICAL_ID,
"ImageUri",
"sha256:81d2ff8422e3a78dc0c1eff53d8e46f5666a801b17b5607a920860c2d234f9d0",
)


@skipIf(
# Hits public ECR pull limitation, move it to canary tests
(not RUN_BY_CANARY and not CI_OVERRIDE),
Expand Down
54 changes: 54 additions & 0 deletions tests/integration/deploy/test_deploy_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,60 @@ def test_no_package_and_deploy_with_s3_bucket_all_args(self, template_file):
deploy_process_execute = self.run_command(deploy_command_list)
self.assertEqual(deploy_process_execute.process.returncode, 0)

@parameterized.expand(["template-image-load.yaml"])
def test_deploy_directly_from_image_archive(self, template_file):
template_path = self.test_data_path.joinpath(template_file)

stack_name = self._method_to_stack_name(self.id())
self.stacks.append({"name": stack_name})

# Package and Deploy in one go without confirming change set.
deploy_command_list = self.get_deploy_command_list(
template_file=template_path,
stack_name=stack_name,
capabilities="CAPABILITY_IAM",
s3_prefix=self.s3_prefix,
s3_bucket=self.s3_bucket.name,
image_repository=self.ecr_repo_name,
force_upload=True,
notification_arns=self.sns_arn,
parameter_overrides="Parameter=Clarity",
kms_key_id=self.kms_key,
no_execute_changeset=False,
tags="integ=true clarity=yes foo_bar=baz",
confirm_changeset=False,
)

deploy_process_execute = self.run_command(deploy_command_list)
self.assertEqual(deploy_process_execute.process.returncode, 0)

@parameterized.expand(["template-image-load-fail.yaml"])
def test_deploy_directly_from_image_archive_but_error_fail(self, template_file):
template_path = self.test_data_path.joinpath(template_file)

stack_name = self._method_to_stack_name(self.id())
self.stacks.append({"name": stack_name})

# Package and Deploy in one go without confirming change set.
deploy_command_list = self.get_deploy_command_list(
template_file=template_path,
stack_name=stack_name,
capabilities="CAPABILITY_IAM",
s3_prefix=self.s3_prefix,
s3_bucket=self.s3_bucket.name,
image_repository=self.ecr_repo_name,
force_upload=True,
notification_arns=self.sns_arn,
parameter_overrides="Parameter=Clarity",
kms_key_id=self.kms_key,
no_execute_changeset=False,
tags="integ=true clarity=yes foo_bar=baz",
confirm_changeset=False,
)

deploy_process_execute = self.run_command(deploy_command_list)
self.assertEqual(deploy_process_execute.process.returncode, 0)

@parameterized.expand(
[
"aws-serverless-function-image.yaml",
Expand Down
Loading
Loading