Skip to content

feat(package): Add sam package support for MicrovmImage - #9399

Open
licjun wants to merge 9 commits into
aws:developfrom
licjun:feat-microvm-image-package
Open

licjun wants to merge 9 commits into
aws:developfrom
licjun:feat-microvm-image-package

Conversation

@licjun

@licjun licjun commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Which issue(s) does this change fix?

Part of #9283. #9357 added sam build support; this PR adds sam package support for local MicrovmImage code, which sam deploy uses too.

Why is this change necessary?

A MicrovmImage is deployed as a zip of its source directory (Dockerfile plus application code), which the MicroVM service builds remotely. Until now sam package failed for a MicrovmImage with local code (a placeholder added in #9357), so users had to zip and upload the source themselves.

How does it address the issue?

The placeholder exporters in samcli/lib/package/packageable_resources.py are replaced with exporters that upload the code:

  • AWS::Serverless::MicrovmImage (CodeUri): a ResourceZip with FORCE_ZIP. A local directory is zipped and uploaded, and CodeUri is set to the S3 URI. This matches AWS::Serverless::Function.
  • AWS::Lambda::MicrovmImage (CodeArtifact): CloudFormation requires CodeArtifact: {Uri: s3://...}. A local path is accepted as {Uri: ./src} or as a plain string, the form CDK templates and built templates use, and is written back as {Uri: <S3 URI>}. get_property_value reads the S3 URI from either form, for sam delete.
  • For both types, a local .zip file is uploaded as is. Intrinsics are left unchanged, and an S3 URI is not uploaded again. A missing code path is not packaged (PACKAGE_NULL_PROPERTY = False), so the template directory is never uploaded in its place.
  • Fn::ForEach templates (--language-extensions): the packaging map is derived from RESOURCES_WITH_LOCAL_PATHS, which already lists both types. Before expansion, a local AWS::Lambda::MicrovmImage CodeArtifact given as a plain string is wrapped as {Uri: ...}, so a dynamic ./${N} is packaged like {Uri: ./${N}} and ends as {Uri: !FindInMap [...]}.

A CodeUri or CodeArtifact that points at a single file other than a .zip, such as the Dockerfile, fails with a message that suggests the directory that contains it, as sam build does. Otherwise the package step does not validate the contents of the source, the same as for functions.

The PR also improves a sam build error: when a MicrovmImage CodeUri is a file, a .zip now gets its own message (point at the source directory to build locally; sam package uploads the .zip as is), and any other file, such as a Dockerfile, gets a message that suggests the directory that contains it.

What side effects does this change have?

  • sam package and sam deploy now upload local MicrovmImage code instead of failing.
  • For AWS::Lambda::MicrovmImage, a CodeArtifact given as a plain string, local path or S3 URI, is written as {Uri: ...} in the packaged template, the object form CloudFormation requires. An S3 URI is not uploaded again. This matches the Lambda function and layer exporters, which write an S3 string Code or Content as {S3Bucket, S3Key}.

Testing

  • Unit tests in test_artifact_exporter.py cover a local directory, both CodeArtifact forms, a local .zip, S3 URIs, intrinsics, a missing value, a missing directory, get_property_value, and a full template export.
  • Integration tests: the zip package tests now also run an AWS::Serverless::MicrovmImage and an AWS::Lambda::MicrovmImage template, in every test that runs the function templates.
  • sam build followed by sam package on the built template, with the S3 upload stubbed, for both resource types and for the Fn::ForEach fixture.
  • End to end in a real account (us-east-1, base image al2023-1 version 2): sam build, then sam deploy --resolve-s3 --capabilities CAPABILITY_IAM on an AWS::Serverless::MicrovmImage with a local CodeUri and no BuildRoleArn. The stack was created, the uploaded zip contained the Dockerfile and app code at its root, the transformed AWS::Lambda::MicrovmImage had CodeArtifact: {Uri: <that S3 URI>}, and the MicroVM service built the image (state: CREATED, active version 1.0). sam delete then removed the stack, the image, and the uploaded zip.

Mandatory Checklist

PRs will only be reviewed after checklist is complete

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

sam package now zips a MicrovmImage source directory, uploads it to S3
and points the template at the S3 URI. The MicroVM service builds the
image from that zip. This replaces the exporters that failed for local
code.

- AWS::Serverless::MicrovmImage: CodeUri is set to the S3 URI.
- AWS::Lambda::MicrovmImage: CodeArtifact is written as {Uri: <S3 URI>},
  which CloudFormation requires. A local path is accepted as {Uri: ...}
  or as a plain string, the form CDK and sam build use.

S3 URIs and intrinsics are left unchanged. A missing code path is not
packaged, so the template directory is never uploaded instead.
A MicrovmImage CodeUri that is a file gave one message that always
mentioned .zip files, even for a file such as a Dockerfile. For a .zip,
explain that sam build needs the source directory and that sam package
uploads the .zip as is. For any other file, suggest the directory that
contains it.
@github-actions github-actions Bot added area/build sam build command pr/internal labels Nov 3, 2026
@aws-sam-tooling-bot

aws-sam-tooling-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

🧪 Hands-on test: No problems observed in 7 scenarios

Commit 2bf3e89 · build LambdaToolingHandsOn-NoDocker:ed14a6c1-07c6-448e-ae1a-94e6c9ad523e. The plan's scenarios ran on an isolated build host; a problem is reported only if it reproduced at least twice. Passing scenarios show only that these checks behaved as expected, not that the change is correct.

Scenarios

Scenario Planned check Result Runner's note
S1 Main path for AWS::Serverless::MicrovmImage: a local CodeUri directory is zipped with the Dockerfile and app code at th… ✅ pass Head: sam package (S3 upload replaced by a local copy) exits 0 with one FAKE_UPLOAD. packaged.yaml has CodeUri: s3://test-bucket/d8f9eb9591f92387e632bd02c4fd19bc; Name, BaseImageArn and BaseImageVers…
S2 AWS::Lambda::MicrovmImage CodeArtifact forms: {Uri: ./app}, the plain string ./app, a directory name with a space, a pl… ✅ pass Head: EXIT=0, three FAKE_UPLOADs. DictForm and StringForm both become CodeArtifact {Uri: s3://test-bucket/d8f9eb95...} (StringForm is now a mapping). SpaceDir ('./my app') gets a different md5 (2d613…
S3 Error paths the PR adds. A Dockerfile, or a corrupt file named .zip, given as the code path is rejected with a suggeste… ✅ pass The plan's tr -dc a-z names the Dockerfile templates *-appockerfile.yaml, so the first run (07/08) hit 'Template file not found' for those two; the harness was fixed and rerun (09/10). Head: app/Do…
S4 A missing code value is not packaged, so the template directory is never zipped in its place (PACKAGE_NULL_PROPERTY=Fal… ✅ pass Head: 'missing' (AWS::Lambda::MicrovmImage without CodeArtifact) EXIT=0, resource written back with no CodeArtifact added, no upload. 'missing-sls' EXIT=1 with SAM translator validation "Missing requ…
S5 Fn::ForEach with --language-extensions: a plain-string CodeArtifact ./${N} and a {Uri: ./${N}} are each packaged per it… ✅ pass Head with --language-extensions: EXIT=0. Both Fn::ForEach blocks are kept. The plain-string body (./${N}) and the dict body ({Uri: ./${M}}) both become CodeArtifact {Uri: {Fn::FindInMap: [SAMUriImage…
S6 The changed sam build error messages: a valid .zip CodeUri gets its own message, and a Dockerfile CodeUri gets a sugges… ✅ pass No Docker on this runner (docker info fails). Untargeted sam build (python -m samcli; no sam entry point in the venv, run 15 hit 'command not found') exits 0 on head and base: the Dockerfile case…
S7 The PR's unit tests pass for the changed modules. ✅ pass Head unit tests: the MicrovmImage-filtered run over test_artifact_exporter, test_language_extensions_packaging, test_package_context and test_app_builder: 37 passed, 416 deselected, 21 subtests passe…
Runner's notes in full
  • S1: Head: sam package (S3 upload replaced by a local copy) exits 0 with one FAKE_UPLOAD. packaged.yaml has CodeUri: s3://test-bucket/d8f9eb9591f92387e632bd02c4fd19bc; Name, BaseImageArn and BaseImageVersion unchanged. The uploaded zip holds exactly ['Dockerfile', 'app.py'] at the root. Base: EXIT=1, 'sam package does not support local code for AWS::Serverless::MicrovmImage yet', NO UPLOADS.
  • S2: Head: EXIT=0, three FAKE_UPLOADs. DictForm and StringForm both become CodeArtifact {Uri: s3://test-bucket/d8f9eb95...} (StringForm is now a mapping). SpaceDir ('./my app') gets a different md5 (2d613988...). S3Dict unchanged; Intrinsic stays {Uri: {Ref: SourceUri}}. S3String (plain-string s3://existing-bucket/src.zip) is WRAPPED as {Uri: s3://existing-bucket/src.zip}, not left as is. This matches the new code docstring; the plan says the PR description claims 'left as is', which I could not check, so it is noted here only. All zips hold ['Dockerfile', 'app.py']. get_property_value returns b/k1, b/k2, then None/None twice. Base: EXIT=1 'does not support local code for AWS::Lambda::MicrovmImage yet', NO UPLOADS, get_property_value always None.
  • S3: The plan's tr -dc a-z names the Dockerfile templates *-appockerfile.yaml, so the first run (07/08) hit 'Template file not found' for those two; the harness was fixed and rerun (09/10). Head: app/Dockerfile and broken.zip for both types exit 1 with "'' is a file. A MicrovmImage source must be a directory containing a Dockerfile, or a .zip of one, for example ''" ( = .../app for the Dockerfile, the template dir for broken.zip), naming CodeUri or CodeArtifact. NO UPLOADS after those. app.zip for both types: EXIT=0, CodeUri / CodeArtifact {Uri} = s3://test-bucket/, upload byte-identical to app.zip (IDENTICAL True). Base: every run fails with 'does not support local code ... yet'.
  • S4: Head: 'missing' (AWS::Lambda::MicrovmImage without CodeArtifact) EXIT=0, resource written back with no CodeArtifact added, no upload. 'missing-sls' EXIT=1 with SAM translator validation "Missing required property 'CodeUri'" (shown as an unexpected-error traceback; base behaves identically, so not caused by this PR). 'nodir' EXIT=1: 'Parameter CodeUri of resource Gone refers to a file or folder that does not exist ...'. show.py: NO UPLOADS, so the template directory (marker.txt) was never zipped. Base: same for missing/missing-sls; nodir fails with 'does not support local code ... yet'.
  • S5: Head with --language-extensions: EXIT=0. Both Fn::ForEach blocks are kept. The plain-string body (./${N}) and the dict body ({Uri: ./${M}}) both become CodeArtifact {Uri: {Fn::FindInMap: [SAMUriImages|SAMUriDicts, {Ref: N|M}, Uri]}}. Mappings has Alpha -> s3://test-bucket/d33b73c0..., Beta -> s3://test-bucket/cbd720e3... (different md5s). Uploaded zips contain ['Dockerfile', 'app.py']. Base: EXIT=1, 'does not support local code for AWS::Lambda::MicrovmImage yet', NO UPLOADS.
  • S6: No Docker on this runner (docker info fails). Untargeted sam build (python -m samcli; no sam entry point in the venv, run 15 hit 'command not found') exits 0 on head and base: the Dockerfile case is skipped for 'no container runtime' and the .zip case for 'CodeUri points to a .zip file', so the changed messages are not reached (17/18). A targeted sam build Img reaches the source checks before any Docker call. Head, EXIT=1: "... app/Dockerfile' for MicrovmImage 'Img' is a file. CodeUri must be a directory containing a Dockerfile, for example '.../app'." and "... app.zip' ... is a .zip file ... point CodeUri at that directory to build locally. sam package uploads a .zip as is." A zip named app.bin also gets the .zip message (content check). Base shows the old '... sam build cannot build a MicrovmImage from a .zip file.' for all.
  • S7: Head unit tests: the MicrovmImage-filtered run over test_artifact_exporter, test_language_extensions_packaging, test_package_context and test_app_builder: 37 passed, 416 deselected, 21 subtests passed. The full run of the three package test files: 280 passed, 18 subtests passed. 0 failed, 0 errors.

🤖 Automated hands-on test — not a review approval · model claude-opus-5.5 · Kiro kiro-cli 2.33.1 · build LambdaToolingHandsOn-NoDocker:ed14a6c1-07c6-448e-ae1a-94e6c9ad523e · commit 2bf3e89

History (5)
Commit Result When (UTC) Build
2bf3e89 No problems observed 2026-11-05 19:17 LambdaToolingHandsOn-NoDocker:ed14a6c1-07c6-448e-ae1a-94e6c9ad523e
dce15e4 No problems observed 2026-11-05 19:04 LambdaToolingHandsOn-Docker:dedd8c4a-7398-4ffb-b287-980afd54053c
5e72d0c No problems observed 2026-11-04 00:33 LambdaToolingHandsOn-Docker:510c9889-1c99-464b-a94f-83895502e134
1d56090 No problems observed 2026-11-04 00:01 LambdaToolingHandsOn-NoDocker:bc4fa66a-4af4-4a42-82a6-f96119885cd5
2798834 Problems reproduced: sam package --language-extensions crashes with TypeError for AWS::Lambda::Micro…; sam package accepts a MicrovmImage CodeUri pointing at a single file (Dockerfil… 2026-11-03 23:40 LambdaToolingHandsOn-Docker:3262159f-1e6b-488b-9e60-e78b2f5c07ac

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Hands-on test reproduced 2 new problems on commit 2798834. Commands and evidence are in the hands-on comment.

  1. [BUG] sam package --language-extensions crashes with TypeError for AWS::Lambda::MicrovmImage in Fn::ForEach using plain-string CodeArtifact: ./${N} · medium · samcli/lib/package/language_extensions_packaging.py:342
  2. [UX] sam package accepts a MicrovmImage CodeUri pointing at a single file (Dockerfile) and silently uploads a zip with only that file · low · samcli/lib/package/packageable_resources.py:774

🤖 Automated hands-on test — not a review approval


temp_dir = None
if is_local_file(local_path) and not is_zip_file(local_path):
temp_dir = copy_to_temp_dir(local_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Hands-on: [UX] sam package accepts a MicrovmImage CodeUri pointing at a single file (Dockerfile) and silently uploads a zip with only that file (reproduced 2 times in S3). Commands and evidence: hands-on comment.

Add an AWS::Serverless::MicrovmImage and an AWS::Lambda::MicrovmImage
template and run them in every zip package test that runs the
function templates.
With --language-extensions, an AWS::Lambda::MicrovmImage in Fn::ForEach
with a plain-string CodeArtifact such as ./${N} crashed with a
TypeError. The exporter writes CodeArtifact back as {Uri: <S3 URI>}, and
copying CodeArtifact.Uri into a body that still held a string failed. A
dynamic string would also have become an Fn::FindInMap string, while
CloudFormation requires the object form.

Before expansion, wrap a local string CodeArtifact as {Uri: <string>},
so it is packaged like the {Uri: ./${N}} form. S3 URIs and intrinsics
are left unchanged.
A MicrovmImage CodeUri or CodeArtifact that pointed at a single file,
such as the Dockerfile, was zipped on its own and uploaded, so files the
Dockerfile copies were left out and the remote build failed. Fail
instead, and suggest the directory that contains the file, as sam build
does. A .zip is still uploaded as is.
@licjun
licjun marked this pull request as ready for review November 4, 2026 00:39
@licjun
licjun requested a review from a team as a code owner November 4, 2026 00:39
@licjun
licjun requested a review from bnusunny November 4, 2026 00:39
@licjun

licjun commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Both problems are fixed:

  1. sam package --language-extensions crash for a plain-string CodeArtifact: ./${N} in Fn::ForEach: fixed in 84f3254. The exporter writes CodeArtifact back as {Uri: <S3 URI>}, and copying CodeArtifact.Uri into a Fn::ForEach body that still held a string failed. Before expansion, a local string CodeArtifact is now wrapped as {Uri: ...}, so it is packaged like the {Uri: ./${N}} form and ends as {Uri: !FindInMap [...]}, the object form CloudFormation requires. S3 URIs and intrinsics are left unchanged.
  2. A CodeUri pointing at a single file (Dockerfile) uploaded a zip with only that file: fixed in 5e72d0c. For both MicrovmImage types, a single file that is not a .zip now fails before any upload, with a message that suggests the directory that contains it, as sam build does. A .zip is still uploaded as is.

@bnusunny bnusunny left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall. One test gap to fix before merge, plus three small nits. I also ran sam package --language-extensions end to end, and CloudFormation created MicroVM images from the packaged template.

End-to-end test (5e72d0c, us-east-1)

I used one template with two Fn::ForEach loops over [Alpha, Beta]:

  • AWS::Lambda::MicrovmImage with CodeArtifact: ./${Name} as a plain string. This is the form that crashed before 84f3254.
  • AWS::Serverless::MicrovmImage with CodeUri: ./${Name}.

sam package uploaded one zip per source directory. It kept the Fn::ForEach blocks and wrote CodeArtifact: {Uri: !FindInMap [...]} and CodeUri: !FindInMap [...], with the per-name S3 URIs in Mappings. I deployed the packaged template with aws cloudformation deploy, so SAM did not package it again. All four images reached CREATE_COMPLETE, and the MicroVM service built each one from its zip.

The packaging itself had no problems. Two notes for anyone repeating the test:

  • AWS::Lambda::MicrovmImage fails CloudFormation early validation unless BuildRoleArn, Hooks, CpuConfigurations, Logging, Resources, EnvironmentVariables, EgressNetworkConnectors, AdditionalOsCapabilities and Description are all set. The SAM transform fills these in, so tests/integration/testdata/package/aws-lambda-microvmimage.yaml packages but would not deploy as written.
  • The image must start a long-running server. With the microvm-image-foreach build fixtures, which have no CMD, the Docker build succeeds but the image never stabilizes.

Findings

  1. Test gap (please fix). The two calls to wrap_microvm_image_code_artifact_strings have no tests (package_context.py:264, artifact_exporter.py:318). If you delete either call, every package unit test still passes. Deleting the top-level one brings back the TypeError from the hands-on report. The new tests call the helper directly, and no integ fixture uses Fn::ForEach. A PackageContext-level test with a plain-string ./${N} in Fn::ForEach, plus a nested-stack version, would cover both calls.
  2. Nit. FORCE_ZIP = True on both MicroVM exporters never takes effect. _check_microvm_image_source rejects single non-zip files first, and LambdaMicrovmImageResource.export never reads the flag. It suggests that a single file gets zipped, which can't happen. Drop it or add a comment.
  3. Low. A plain-string S3 CodeArtifact: s3://... is left as a string, but CloudFormation requires {Uri: ...}. Local strings are already rewritten into that form, so S3 strings could be too. This isn't a regression.
  4. Nit. sam build decides whether a file is a .zip from its extension (app_builder.py:1219). sam package checks whether the file really is a zip. So a corrupt x.zip gets "sam package uploads a .zip as is" from build, and then package rejects it as a file.

Checks: CI is green. 695 unit tests pass in the package and build suites. I broke 11 of the new branches one at a time to see whether a test fails. 9 were caught; the two that weren't are the calls in finding 1.

bnusunny

This comment was marked as duplicate.

Neither call to wrap_microvm_image_code_artifact_strings had a test, so
removing one still passed. Package a plain-string CodeArtifact in
Fn::ForEach through PackageContext and through a nested stack, and check
that each ends as {Uri: Fn::FindInMap}.
A single non-zip file is rejected before export, and the Lambda exporter
never read the flag, so FORCE_ZIP had no effect and suggested that a
single file is zipped.
sam build chose its .zip message from the file extension, while sam
package checks the content. A corrupt x.zip was told that sam package
uploads it as is, and then package rejected it as a file. Check the
content in build too.
CloudFormation requires AWS::Lambda::MicrovmImage CodeArtifact as an
object, but an S3 URI given as a plain string was left as a string, so
the packaged template could not deploy. Local strings were already
rewritten. Write S3 strings as {Uri: <S3 URI>} too, without uploading,
as the Lambda function and layer exporters write an S3 string Code or
Content as {S3Bucket, S3Key}. This also applies inside Fn::ForEach.
@licjun

licjun commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

@bnusunny Thanks for the review and the end-to-end run. All four findings are addressed:

  1. Test gap: 7b00d8e adds a PackageContext test and a nested-stack test, each packaging a plain-string CodeArtifact: ./${N} in Fn::ForEach and checking for {Uri: Fn::FindInMap}. Removing either wrap_microvm_image_code_artifact_strings call now fails its test, and only that one.
  2. Unused FORCE_ZIP: removed from both MicrovmImage exporters in 7b5feca. Directories are still zipped, a .zip is uploaded as is, and a single other file is rejected.
  3. S3 string CodeArtifact: 2bf3e89 writes it as {Uri: s3://...} without uploading, like the Lambda function and layer exporters, which write an S3 string Code or Content as {S3Bucket, S3Key}. This also applies inside Fn::ForEach, including s3://bucket/${N}.zip.
  4. .zip detection: dce15e4 makes sam build check the file content, as sam package does, so a corrupt x.zip gets the "is a file" message from both. The build-all skip (is_microvm_image_buildable) still goes by the extension, like the layer check.

On your deploy notes: aws-lambda-microvmimage.yaml is only used by the package integ tests, so I left it as is.

@licjun
licjun requested a review from bnusunny November 5, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/build sam build command pr/internal

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants