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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ environment that lets you locally build, test, debug, and deploy [AWS serverless

* [Install SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html)
* [Build & Deploy a "Hello World" Web App](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-getting-started-hello-world.html)
* [Package local MicroVM image code](docs/microvm-image-packaging.md)
* [Install AWS Toolkit](https://builder.aws.com/build/tools) to use SAM with your favorite IDEs
* [Tutorials and Workshops](https://serverlessland.com/learn)
* **Powertools for AWS Lambda** is a developer toolkit to implement Serverless best practices and increase developer velocity. Available for [Python](https://docs.aws.amazon.com/powertools/python/latest/), [Java](https://github.com/aws-powertools/powertools-lambda-java), [TypeScript](https://github.com/aws-powertools/powertools-lambda-typescript) and [.NET](https://github.com/aws-powertools/powertools-lambda-dotnet).
Expand Down
42 changes: 42 additions & 0 deletions docs/microvm-image-packaging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Package local MicroVM image code

For `AWS::Serverless::MicrovmImage`, `CodeUri` can point to a local directory
containing the Dockerfile and application code, or to an existing ZIP file:

```yaml
Resources:
Image:
Type: AWS::Serverless::MicrovmImage
Properties:
Name: my-image
BaseImageArn: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:aws:microvm-image:al2023-1
BaseImageVersion: '0'
CodeUri: ./microvm-code/
```

Run `sam package --s3-bucket <artifact-bucket> --output-template-file packaged.yaml`,
or use `sam deploy --resolve-s3` to package as part of deployment. Directories are
zipped through the existing artifact uploader; ZIP files are uploaded unchanged.
The packaged `CodeUri` is a string S3 URI. The existing content-derived object key
changes when the source changes and allows unchanged artifacts to be reused.
Existing S3 URIs and intrinsic references are left unchanged.

`sam build` does not execute the Dockerfile or compile MicroVM code as a Lambda
function. The AWS service builds the MicroVM image after deployment. It preserves
the source reference when relocating the template into `.aws-sam/build`, including
custom build directories; packaging that template resolves the original context.
Keep that source context available until packaging completes. `--base-dir` and
Lambda runtime/build-method options do not introduce a MicroVM build workflow.

The uploader uses the existing directory archive behavior, not `.dockerignore`
or a new `.samignore` filter. Keep only intended artifacts in the selected source
directory, or prepare a ZIP with the desired exclusions. A missing `CodeUri` does
not implicitly upload the template directory; the property remains required.

The selected bucket must be readable by the MicroVM build role. Custom KMS keys
also require the role's `kms:Decrypt` permission. This feature does not change
bucket policies, IAM roles, or service-side image creation.

Registering the artifact also makes it eligible for the existing `sam delete`
artifact cleanup, subject to the resource's retention settings, as with other
packageable resources.
11 changes: 11 additions & 0 deletions samcli/lib/package/packageable_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
AWS_SERVERLESS_GRAPHQLAPI,
AWS_SERVERLESS_HTTPAPI,
AWS_SERVERLESS_LAYERVERSION,
AWS_SERVERLESS_MICROVMIMAGE,
AWS_SERVERLESS_STATEMACHINE,
AWS_SERVERLESSREPO_APPLICATION,
AWS_STEPFUNCTIONS_STATEMACHINE,
Expand Down Expand Up @@ -404,6 +405,15 @@ class ServerlessFunctionImageResource(ResourceImage):
FORCE_ZIP = False


class ServerlessMicrovmImageResource(ResourceZip):
RESOURCE_TYPE = AWS_SERVERLESS_MICROVMIMAGE
PROPERTY_NAME = RESOURCES_WITH_LOCAL_PATHS[RESOURCE_TYPE][0]
FORCE_ZIP = True
# Unlike Function.CodeUri, this property is required. Never upload the
# template directory implicitly when it is absent.
PACKAGE_NULL_PROPERTY = False


class ServerlessApiResource(ResourceZip):
RESOURCE_TYPE = AWS_SERVERLESS_API
PROPERTY_NAME = RESOURCES_WITH_LOCAL_PATHS[RESOURCE_TYPE][0]
Expand Down Expand Up @@ -692,6 +702,7 @@ def export(self, resource_id: str, resource_dict: Optional[Dict], parent_dir: st
RESOURCES_EXPORT_LIST = [
ServerlessFunctionResource,
ServerlessFunctionImageResource,
ServerlessMicrovmImageResource,
ServerlessApiResource,
ServerlessHttpApiResource,
ServerlessStateMachineResource,
Expand Down
2 changes: 2 additions & 0 deletions samcli/lib/utils/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
# Lambda
AWS_SERVERLESS_FUNCTION = "AWS::Serverless::Function"
AWS_SERVERLESS_LAYERVERSION = "AWS::Serverless::LayerVersion"
AWS_SERVERLESS_MICROVMIMAGE = "AWS::Serverless::MicrovmImage"

AWS_LAMBDA_FUNCTION = "AWS::Lambda::Function"
AWS_LAMBDA_LAYERVERSION = "AWS::Lambda::LayerVersion"
Expand Down Expand Up @@ -66,6 +67,7 @@
RESOURCES_WITH_LOCAL_PATHS = {
AWS_SERVERLESS_GRAPHQLAPI: [SCHEMA_ARTIFACT_PROPERTY, CODE_ARTIFACT_PROPERTY],
AWS_SERVERLESS_FUNCTION: ["CodeUri"],
AWS_SERVERLESS_MICROVMIMAGE: ["CodeUri"],
AWS_SERVERLESS_API: ["DefinitionUri"],
AWS_SERVERLESS_HTTPAPI: ["DefinitionUri"],
AWS_SERVERLESS_STATEMACHINE: ["DefinitionUri"],
Expand Down
Empty file.
177 changes: 177 additions & 0 deletions tests/functional/lib/package/test_microvm_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""Exercise MicrovmImage packaging with real archives/hashes and mocked S3 I/O."""

import copy
import io
import zipfile
from pathlib import Path
from unittest.mock import Mock, patch

import pytest
from botocore.exceptions import ClientError

from samcli.commands._utils.template import get_template_artifacts_format, move_template
from samcli.commands.package.exceptions import ExportFailedError
from samcli.lib.package.artifact_exporter import Template
from samcli.lib.package.s3_uploader import S3Uploader
from samcli.lib.package.uploaders import Uploaders
from samcli.lib.utils.packagetype import ZIP
from samcli.yamlhelper import yaml_parse


@pytest.fixture
def package(tmp_path):
objects = {}
client = Mock()
client.meta.endpoint_url = "https://s3.example.com"
transfer = Mock()

def head_object(Bucket, Key):
if Key not in objects:
raise ClientError({"Error": {"Code": "404"}}, "HeadObject")
return {}

def upload(filename, bucket, key, extra_args):
objects[key] = Path(filename).read_bytes()
assert extra_args == {"ServerSideEncryption": "AES256"}
return Mock()

client.head_object.side_effect = head_object
transfer.upload.side_effect = upload
with patch("samcli.lib.package.s3_uploader.transfer.create_transfer_manager", return_value=transfer):
uploader = S3Uploader(client, "test-bucket", prefix="artifacts", no_progressbar=True)
signer = Mock()
signer.should_sign_package.return_value = False

def export(template, template_path=None):
return Template(
str(template_path or tmp_path / "template.yaml"),
str(tmp_path),
Uploaders(uploader, Mock()),
signer,
template_dict=copy.deepcopy(template),
).export()

return export, objects, transfer


def template(code_uri):
return {
"Transform": "AWS::Serverless-2016-10-31",
"Resources": {
"Image": {
"Type": "AWS::Serverless::MicrovmImage",
"Properties": {"Name": "test-image", "CodeUri": code_uri, "BaseImageVersion": "0"},
}
},
}


@pytest.mark.parametrize("absolute", [False, True])
@pytest.mark.parametrize("newline", [b"\n", b"\r\n"], ids=["lf", "crlf"])
def test_local_directory_uses_template_directory_and_content_hash(package, tmp_path, monkeypatch, absolute, newline):
export, objects, transfer = package
source = tmp_path / "source"
source.mkdir()
dockerfile = b"FROM scratch" + newline + b"COPY app.py /app.py" + newline
(source / "Dockerfile").write_bytes(dockerfile)
app = source / "app.py"
original_bytes = b"print('first')" + newline
app.write_bytes(original_bytes)
unrelated = tmp_path / "unrelated"
unrelated.mkdir()
monkeypatch.chdir(unrelated)
original = template(str(source) if absolute else "source")

first = export(original)["Resources"]["Image"]["Properties"]["CodeUri"]
assert first.startswith("s3://test-bucket/artifacts/")
key = first.removeprefix("s3://test-bucket/")
with zipfile.ZipFile(io.BytesIO(objects[key])) as archive:
assert set(archive.namelist()) == {"Dockerfile", "app.py"}
assert archive.read("Dockerfile") == dockerfile
assert archive.read("app.py") == original_bytes
assert export(original)["Resources"]["Image"]["Properties"]["CodeUri"] == first
assert transfer.upload.call_count == 1

app.write_bytes(b"print('changed')" + newline)
changed = export(original)["Resources"]["Image"]["Properties"]["CodeUri"]
assert changed != first
assert transfer.upload.call_args.args[2] == changed.removeprefix("s3://test-bucket/")


def test_existing_local_zip_is_uploaded_without_rezipping(package, tmp_path):
export, objects, _ = package
artifact = tmp_path / "app.zip"
with zipfile.ZipFile(artifact, "w") as archive:
archive.writestr("Dockerfile", "FROM scratch\n")
result = export(template("app.zip"))["Resources"]["Image"]["Properties"]["CodeUri"]
assert objects[result.removeprefix("s3://test-bucket/")] == artifact.read_bytes()


@pytest.mark.parametrize(
"code_uri", ["s3://existing/code.zip", {"Ref": "ArtifactUri"}, {"Fn::Sub": "s3://${Bucket}/code.zip"}]
)
def test_remote_and_intrinsic_references_are_unchanged(package, code_uri):
export, objects, transfer = package
assert export(template(code_uri))["Resources"]["Image"]["Properties"]["CodeUri"] == code_uri
assert not objects
transfer.upload.assert_not_called()


@pytest.mark.parametrize("code_uri", [None, ""])
def test_missing_required_code_uri_does_not_upload_template_directory(package, code_uri):
export, objects, transfer = package
document = template(code_uri)
if code_uri is None:
del document["Resources"]["Image"]["Properties"]["CodeUri"]
assert export(document) == document
assert not objects
transfer.upload.assert_not_called()


def test_nonexistent_local_path_reports_resource_and_property(package):
export, _, _ = package
with pytest.raises(ExportFailedError, match="CodeUri.*Image"):
export(template("does-not-exist"))


def test_build_template_relocation_preserves_local_code_uri(package, tmp_path):
export, objects, _ = package
source = tmp_path / "source"
source.mkdir()
(source / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
destination = tmp_path / ".aws-sam" / "build" / "template.yaml"
destination.parent.mkdir(parents=True)
# This is the relocation path used by sam build for resources without a
# Lambda build artifact. The AWS service builds the MicroVM image itself.
move_template(str(tmp_path / "template.yaml"), str(destination), template("source"))
built = yaml_parse(destination.read_text(encoding="utf-8"))
assert get_template_artifacts_format(str(destination)) == [ZIP]
uri = built["Resources"]["Image"]["Properties"]["CodeUri"]
assert (destination.parent / uri).resolve() == source
packaged = export(built, destination)
assert packaged["Resources"]["Image"]["Properties"]["CodeUri"].startswith("s3://test-bucket/")
assert objects


def test_nested_stack_packages_microvm_source(package, tmp_path):
export, objects, _ = package
child_dir = tmp_path / "child"
source = child_dir / "source"
source.mkdir(parents=True)
(source / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
from samcli.yamlhelper import yaml_dump

(child_dir / "template.yaml").write_text(yaml_dump(template("source")), encoding="utf-8")
parent = {
"Resources": {
"Child": {"Type": "AWS::CloudFormation::Stack", "Properties": {"TemplateURL": "child/template.yaml"}}
}
}
packaged = export(parent)
uploaded_child = packaged["Resources"]["Child"]["Properties"]["TemplateURL"]
# Nested templates use an HTTPS S3 URL; inspect the uploaded template bytes.
child_key = next(key for key, data in objects.items() if not zipfile.is_zipfile(io.BytesIO(data)))
assert uploaded_child.endswith(child_key)
child = yaml_parse(objects[child_key].decode("utf-8"))
uri = child["Resources"]["Image"]["Properties"]["CodeUri"]
assert uri.removeprefix("s3://test-bucket/") in objects
24 changes: 24 additions & 0 deletions tests/integration/buildcmd/test_build_cmd_microvm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""MicroVM source contexts are packaged, not compiled as Lambda functions."""

from pathlib import Path

from parameterized import parameterized

from samcli.yamlhelper import yaml_parse
from tests.integration.buildcmd.build_integ_base import BuildIntegBase
from tests.testing_utils import run_command


class TestBuildMicrovmImage(BuildIntegBase):
template = "microvm_image/template.yaml"

@parameterized.expand([False, True])
def test_build_relocates_source_context(self, custom_build_dir):
build_dir = self.custom_build_dir if custom_build_dir else None
result = run_command(self.get_command_list(build_dir=build_dir), cwd=self.working_dir)
self.assertEqual(result.process.returncode, 0, result.stderr)
output = Path(self.custom_build_dir if custom_build_dir else self.default_build_dir) / "template.yaml"
built = yaml_parse(output.read_text(encoding="utf-8"))
source = built["Resources"]["Image"]["Properties"]["CodeUri"]
self.assertEqual((output.parent / source).resolve(), Path(self.template_path).parent / "source")
self.assertTrue((output.parent / source / "Dockerfile").is_file())
1 change: 1 addition & 0 deletions tests/integration/package/test_package_command_zip.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def test_package_nested_template(self, template_file, uploading_count):
[
"cdk_v1_synthesized_template_zip_functions.json",
"aws-serverless-function.yaml",
"aws-serverless-microvmimage.yaml",
"aws-serverless-api.yaml",
"aws-serverless-httpapi.yaml",
"aws-appsync-graphqlschema.yaml",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FROM scratch
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Transform: AWS::Serverless-2016-10-31
Resources:
Image:
Type: AWS::Serverless::MicrovmImage
Properties:
Name: test-image
BaseImageArn: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:aws:microvm-image:al2023-1
BaseImageVersion: '0'
CodeUri: source
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Transform: AWS::Serverless-2016-10-31
Resources:
Image:
Type: AWS::Serverless::MicrovmImage
Properties:
Name: test-image
BaseImageArn: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:aws:microvm-image:al2023-1
BaseImageVersion: '0'
CodeUri: microvmimage-source
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FROM scratch
2 changes: 2 additions & 0 deletions tests/unit/lib/package/test_artifact_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
ResourceWithS3UrlDict,
ServerlessApiResource,
ServerlessFunctionResource,
ServerlessMicrovmImageResource,
GraphQLSchemaResource,
LambdaFunctionResource,
ApiGatewayRestApiResource,
Expand Down Expand Up @@ -125,6 +126,7 @@ def test_all_resources_export(self):

setup = [
{"class": ServerlessFunctionResource, "expected_result": uploaded_s3_url},
{"class": ServerlessMicrovmImageResource, "expected_result": uploaded_s3_url},
{"class": ServerlessApiResource, "expected_result": uploaded_s3_url},
{"class": GraphQLSchemaResource, "expected_result": uploaded_s3_url},
{"class": AppSyncResolverCodeResource, "expected_result": uploaded_s3_url},
Expand Down