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: Consume image asset metadata from CDK synth #3492

Merged
merged 12 commits into from
Dec 3, 2021
1 change: 1 addition & 0 deletions appveyor-windows-build-nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ test_script:
- "venv\\Scripts\\activate"
- "docker system prune -a -f"
- "pytest -vv tests/integration/buildcmd/test_build_cmd.py -k TestBuildCommand_NodeFunctions"
- "pytest -vv tests/integration/local/invoke/invoke_cdk_templates.py -k TestCDKSynthesizedTemplatesFunctions"

# Uncomment for RDP
# on_finish:
Expand Down
72 changes: 70 additions & 2 deletions samcli/lib/samlib/resource_metadata_normalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@
"""

import logging
from pathlib import Path

RESOURCES_KEY = "Resources"
PROPERTIES_KEY = "Properties"
METADATA_KEY = "Metadata"

ASSET_PATH_METADATA_KEY = "aws:asset:path"
ASSET_PROPERTY_METADATA_KEY = "aws:asset:property"

IMAGE_ASSET_PROPERTY = "Code.ImageUri"
ASSET_DOCKERFILE_PATH_KEY = "aws:asset:dockerfile-path"
ASSET_DOCKERFILE_BUILD_ARGS_KEY = "aws:asset:docker-build-args"

SAM_METADATA_DOCKERFILE_KEY = "Dockerfile"
SAM_METADATA_DOCKER_CONTEXT_KEY = "DockerContext"
SAM_METADATA_DOCKER_BUILD_ARGS_KEY = "DockerBuildArgs"

LOG = logging.getLogger(__name__)


Expand All @@ -31,9 +41,17 @@ def normalize(template_dict):

for logical_id, resource in resources.items():
resource_metadata = resource.get(METADATA_KEY, {})
asset_path = resource_metadata.get(ASSET_PATH_METADATA_KEY)
asset_property = resource_metadata.get(ASSET_PROPERTY_METADATA_KEY)

if asset_property == IMAGE_ASSET_PROPERTY:
asset_metadata = ResourceMetadataNormalizer._extract_image_asset_metadata(resource_metadata)
ResourceMetadataNormalizer._update_resource_image_asset_metadata(resource_metadata, asset_metadata)
# For image-type functions, the asset path is expected to be the name of the Docker image.
# When building, we set the name of the image to be the logical id of the function.
asset_path = logical_id.lower()
mildaniel marked this conversation as resolved.
Show resolved Hide resolved
else:
asset_path = resource_metadata.get(ASSET_PATH_METADATA_KEY)

ResourceMetadataNormalizer._replace_property(asset_property, asset_path, resource, logical_id)

@staticmethod
Expand All @@ -56,10 +74,60 @@ def _replace_property(property_key, property_value, resource, logical_id):

"""
if property_key and property_value:
resource.get(PROPERTIES_KEY, {})[property_key] = property_value
nested_keys = property_key.split(".")
mildaniel marked this conversation as resolved.
Show resolved Hide resolved
target_dict = resource.get(PROPERTIES_KEY, {})
while len(nested_keys) > 1:
key = nested_keys.pop(0)
target_dict[key] = {}
target_dict = target_dict[key]
target_dict[nested_keys[0]] = property_value
elif property_key or property_value:
LOG.info(
"WARNING: Ignoring Metadata for Resource %s. Metadata contains only aws:asset:path or "
"aws:assert:property but not both",
logical_id,
)

@staticmethod
def _extract_image_asset_metadata(metadata):
mildaniel marked this conversation as resolved.
Show resolved Hide resolved
"""
Extract/create relevant metadata properties for image assets

Parameters
----------
metadata dict
Metadata to use for extracting image assets properties

Returns
-------
dict
metadata properties for image-type lambda function

"""
asset_path = Path(metadata.get(ASSET_PATH_METADATA_KEY, ""))
dockerfile_path = Path(metadata.get(ASSET_DOCKERFILE_PATH_KEY), "")
dockerfile, path_from_asset = dockerfile_path.stem, dockerfile_path.parent
dockerfile_context = str(Path(asset_path.joinpath(path_from_asset)))
return {
SAM_METADATA_DOCKERFILE_KEY: dockerfile,
SAM_METADATA_DOCKER_CONTEXT_KEY: dockerfile_context,
SAM_METADATA_DOCKER_BUILD_ARGS_KEY: metadata.get(ASSET_DOCKERFILE_BUILD_ARGS_KEY, {}),
}

@staticmethod
def _update_resource_image_asset_metadata(metadata, updated_values):
"""
Update the metadata values for image-type lambda functions

This method will mutate the template

Parameters
----------
metadata dict
Metadata dict to be updated
updated_values dict
Dict of key-value pairs to append to the existing metadata

"""
for key, val in updated_values.items():
metadata[key] = val
78 changes: 78 additions & 0 deletions tests/integration/local/invoke/invoke_cdk_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import json
import shutil
from pathlib import Path
from unittest import skipIf

import docker
import pytest
from docker.errors import APIError
from parameterized import parameterized

from samcli import __version__ as version
from samcli.local.docker.lambda_image import RAPID_IMAGE_TAG_PREFIX
from tests.integration.local.invoke.invoke_integ_base import InvokeIntegBase
from samcli.lib.utils.architecture import X86_64
from tests.testing_utils import IS_WINDOWS, RUNNING_ON_CI, CI_OVERRIDE


@skipIf(
((IS_WINDOWS and RUNNING_ON_CI) and not CI_OVERRIDE),
"Skip build tests on windows when running in CI unless overridden",
)
class TestCDKSynthesizedTemplatesFunctions(InvokeIntegBase):
mildaniel marked this conversation as resolved.
Show resolved Hide resolved

template = Path("cdk/cdk_template.yaml")
functions = [
"StandardFunctionConstructZipFunction",
"StandardFunctionConstructImageFunction",
"DockerImageFunctionConstruct",
]

@classmethod
def setUpClass(cls):
# Run sam build first to build the image functions
# We only need to create these images once
# We remove them after they are no longer used
super(TestCDKSynthesizedTemplatesFunctions, cls).setUpClass()
build_command_list = super().get_build_command_list(cls, template_path=cls.template_path)
super().run_command(cls, command_list=build_command_list)

def tearDown(self) -> None:
# Tear down a unique image resource after it is finished being used
docker_client = docker.from_env()
mildaniel marked this conversation as resolved.
Show resolved Hide resolved
try:
to_remove = self.teardown_function_name
docker_client.api.remove_image(f"{to_remove.lower()}")
docker_client.api.remove_image(f"{to_remove.lower()}:{RAPID_IMAGE_TAG_PREFIX}-{version}-{X86_64}")
except (APIError, AttributeError):
pass

try:
# We don't actually use the build dir so we don't care if it's removed before another process finishes
shutil.rmtree(str(Path().joinpath(".aws-sam")))
except FileNotFoundError:
pass

@parameterized.expand(functions)
@pytest.mark.flaky(reruns=3)
def test_build_and_invoke_image_function(self, function_name):
mildaniel marked this conversation as resolved.
Show resolved Hide resolved
# Set the function name to be removed during teardown
self.teardown_function_name = function_name
local_invoke_command_list = self.get_command_list(
function_to_invoke=function_name, template_path=self.template_path
)
stdout, _, return_code = self.run_command(local_invoke_command_list)

# Get the response without the sam-cli prompts that proceed it
response = json.loads(stdout.decode("utf-8").split("\n")[0])
expected_response = json.loads('{"statusCode":200,"body":"{\\"message\\":\\"hello world\\"}"}')

self.assertEqual(return_code, 0)
self.assertEqual(response, expected_response)

def test_invoke_with_utf8_event(self):
command_list = self.get_command_list(
"StandardFunctionConstructZipFunction", template_path=self.template_path, event_path=self.event_utf8_path
)
stdout, _, return_code = self.run_command(command_list)
self.assertEqual(return_code, 0)
73 changes: 73 additions & 0 deletions tests/integration/testdata/invoke/cdk/cdk_template.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
Resources:
StandardFunctionConstructImageFunction:
Type: AWS::Lambda::Function
Properties:
Code:
ImageUri:
Fn::Join:
- ""
- - Ref: AWS::AccountId
- .dkr.ecr.
- Ref: AWS::Region
- "."
- Ref: AWS::URLSuffix
- /aws-cdk/assets:ec366e0c559122e6b653100637b3745e7dd2c7bc882572b66fd53f498cc06007
PackageType: Image
Metadata:
aws:cdk:path: ApiCorsIssueStack/Fn/Resource
aws:asset:path: ./lambda_code
aws:asset:dockerfile-path: Dockerfile
aws:asset:property: Code.ImageUri
DockerImageFunctionConstruct:
Type: AWS::Lambda::Function
Properties:
Code:
ImageUri:
Fn::Join:
- ""
- - Ref: AWS::AccountId
- .dkr.ecr.
- Ref: AWS::Region
- "."
- Ref: AWS::URLSuffix
- /aws-cdk/assets:ec366e0c559122e6b653100637b3745e7dd2c7bc882572b66fd53f498cc06007
PackageType: Image
Metadata:
aws:cdk:path: ApiCorsIssueStack/DockerFunc/Resource
aws:asset:path: ./lambda_code
aws:asset:dockerfile-path: Dockerfile
aws:asset:property: Code.ImageUri
StandardFunctionConstructZipFunction:
Type: AWS::Lambda::Function
Properties:
Code:
S3Bucket:
Ref: AssetParametersd993ee10bdd2d5f2054086eb58ff286f13672de94811036fc40c647e0e1b17c7S3BucketEA8808C3
S3Key:
Fn::Join:
- ""
- - Fn::Select:
- 0
- Fn::Split:
- "||"
- Ref: AssetParametersd993ee10bdd2d5f2054086eb58ff286f13672de94811036fc40c647e0e1b17c7S3VersionKeyF3B3F55C
- Fn::Select:
- 1
- Fn::Split:
- "||"
- Ref: AssetParametersd993ee10bdd2d5f2054086eb58ff286f13672de94811036fc40c647e0e1b17c7S3VersionKeyF3B3F55C
Handler: app.lambdaHandler
Runtime: nodejs12.x
Metadata:
aws:cdk:path: ApiCorsIssueStack/Lambda/Resource
aws:asset:path: ./lambda_code
aws:asset:original-path: ./lambda_code
aws:asset:is-bundled: false
aws:asset:property: Code
CDKMetadata:
Type: AWS::CDK::Metadata
Properties:
Analytics: v2:deflate64:H4sIAAAAAAAA/02OzQrCMBCEn8V7um2oeFYrgtf4BDFdS/qTlWyqSMi721QETzPsN7OMhCjrGqrNXr+4MO1QGvII8Rq0GURDjoOfTRDN3Slkmr3B7BfQ2mDJJSHr7X89ZhNHPd1aDfE8O5NjufPzSVg9QVQ0rq+yJoHGF5oZA8OJzID+MukOD/mSGSh8ENtA/n3UvOTXlStelnbWdSkJRy1Cz+VT7kBWy6ierS387IKdENRXPzi+2EPxAAAA
Metadata:
aws:cdk:path: ApiCorsIssueStack/CDKMetadata/Default
Condition: CDKMetadataAvailable
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM public.ecr.aws/lambda/nodejs:14

# Assumes your function is named "app.js", and there is a package.json file in the app directory
COPY app.js ./

# Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile)
CMD [ "app.lambdaHandler" ]
17 changes: 17 additions & 0 deletions tests/integration/testdata/invoke/cdk/lambda_code/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
let response;

exports.lambdaHandler = async (event, context) => {
try {
response = {
'statusCode': 200,
'body': JSON.stringify({
message: 'hello world',
})
}
} catch (err) {
console.log(err);
return err;
}

return response
};
65 changes: 65 additions & 0 deletions tests/unit/lib/samlib/test_resource_metadata_normalizer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import pathlib
from unittest import TestCase

from samcli.lib.samlib.resource_metadata_normalizer import ResourceMetadataNormalizer
Expand Down Expand Up @@ -37,6 +38,70 @@ def test_replace_all_resources_that_contain_metadata(self):
self.assertEqual("new path", template_data["Resources"]["Function1"]["Properties"]["Code"])
self.assertEqual("super cool path", template_data["Resources"]["Resource2"]["Properties"]["SomeRandomProperty"])

def test_replace_all_resources_that_contain_image_metadata(self):
docker_build_args = {"arg1": "val1", "arg2": "val2"}
asset_path = pathlib.Path("/path", "to", "asset")
dockerfile_path = pathlib.Path("path", "to", "Dockerfile")
template_data = {
"Resources": {
"Function1": {
"Properties": {
"Code": {
"ImageUri": "Some Value",
}
},
"Metadata": {
"aws:asset:path": asset_path,
"aws:asset:property": "Code.ImageUri",
"aws:asset:dockerfile-path": dockerfile_path,
"aws:asset:docker-build-args": docker_build_args,
},
},
}
}

ResourceMetadataNormalizer.normalize(template_data)

expected_docker_context_path = str(pathlib.Path("/path", "to", "asset", "path", "to"))
self.assertEqual("function1", template_data["Resources"]["Function1"]["Properties"]["Code"]["ImageUri"])
self.assertEqual(
expected_docker_context_path, template_data["Resources"]["Function1"]["Metadata"]["DockerContext"]
)
self.assertEqual("Dockerfile", template_data["Resources"]["Function1"]["Metadata"]["Dockerfile"])
self.assertEqual(docker_build_args, template_data["Resources"]["Function1"]["Metadata"]["DockerBuildArgs"])

def test_replace_all_resources_that_contain_image_metadata_windows_paths(self):
docker_build_args = {"arg1": "val1", "arg2": "val2"}
asset_path = "C:\\path\\to\\asset"
dockerfile_path = "rel/path/to/Dockerfile"
template_data = {
"Resources": {
"Function1": {
"Properties": {
"Code": {
"ImageUri": "Some Value",
}
},
"Metadata": {
"aws:asset:path": asset_path,
"aws:asset:property": "Code.ImageUri",
"aws:asset:dockerfile-path": dockerfile_path,
"aws:asset:docker-build-args": docker_build_args,
},
},
}
}

ResourceMetadataNormalizer.normalize(template_data)

expected_docker_context_path = str(pathlib.Path("C:\\path\\to\\asset").joinpath(pathlib.Path("rel/path/to")))
self.assertEqual("function1", template_data["Resources"]["Function1"]["Properties"]["Code"]["ImageUri"])
self.assertEqual(
expected_docker_context_path, template_data["Resources"]["Function1"]["Metadata"]["DockerContext"]
)
self.assertEqual("Dockerfile", template_data["Resources"]["Function1"]["Metadata"]["Dockerfile"])
self.assertEqual(docker_build_args, template_data["Resources"]["Function1"]["Metadata"]["DockerBuildArgs"])

def test_tempate_without_metadata(self):
template_data = {"Resources": {"Function1": {"Properties": {"Code": "some value"}}}}

Expand Down