Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7eca282
feat: aws-durable-execution-emulator uses prebuilt image from ECR ins…
hsilan Feb 19, 2026
35f322c
Merge branch 'aws:develop' into feat/use-durable-functions-emulator-i…
SilanHe Feb 26, 2026
58d4e91
chore: attempt using new image vended from python testing library
hsilan Feb 25, 2026
a0045a0
chore: update error message when Durable Functions Emulator container…
hsilan Feb 26, 2026
ea7a340
feat: use new image with emulator built in
hsilan Feb 26, 2026
1430958
chore: lint
hsilan Feb 26, 2026
9452dab
chore: make pr
hsilan Feb 26, 2026
2a2469c
Merge branch 'develop' into feat/use-durable-functions-emulator-image
SilanHe Feb 27, 2026
681ae2d
chore: update env variables, address nits about reusing functions, us…
hsilan Feb 27, 2026
3b0f0c9
Merge branch 'develop' into feat/use-durable-functions-emulator-image
SilanHe Feb 27, 2026
384b9fb
chore: ran make pr + cut down on redundant environment variables
hsilan Feb 27, 2026
9eaa5fe
chore: use friendly public repository alias
hsilan Mar 10, 2026
f51e105
Merge branch 'aws:develop' into feat/use-durable-functions-emulator-i…
SilanHe Mar 10, 2026
338ef3d
chore: remove redundant boto3 credentials, we are already setting the…
hsilan Mar 10, 2026
38840bf
chore: added comments and additional instructions for fetching emulat…
hsilan Mar 10, 2026
afcf727
chore: make pr
hsilan Mar 11, 2026
7471f68
Merge branch 'develop' into feat/use-durable-functions-emulator-image
SilanHe Mar 30, 2026
a3f908f
Reorder import statements in durable_functions_emulator_container.py
SilanHe Mar 30, 2026
6efae88
chore: update test coverage + imports for durable_functions_emulator_…
hsilan Mar 30, 2026
d3733bf
chore: add skip_pull_image support to emulator image + support using …
hsilan Mar 31, 2026
0d281a4
Merge branch 'develop' into feat/use-durable-functions-emulator-image
SilanHe Apr 1, 2026
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
10 changes: 8 additions & 2 deletions samcli/commands/local/cli_common/durable_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,24 @@ class DurableContext:
Automatically reuses existing running containers when possible.
"""

def __init__(self):
def __init__(self, skip_pull_image=False):
"""
Initialize the durable context.

Parameters
----------
skip_pull_image : bool
If True, skip pulling the emulator container image
"""
self._emulator: Optional[DurableFunctionsEmulatorContainer] = None
self._reused_container = False
self._skip_pull_image = skip_pull_image

def __enter__(self) -> "DurableContext":
"""
Start the emulator container or attach to an already running one
"""
self._emulator = DurableFunctionsEmulatorContainer()
self._emulator = DurableFunctionsEmulatorContainer(skip_pull_image=self._skip_pull_image)
self._reused_container = self._emulator.start_or_attach()
return self

Expand Down
156 changes: 64 additions & 92 deletions samcli/local/docker/durable_functions_emulator_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import time
from http import HTTPStatus
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Optional

import docker
Expand All @@ -16,9 +15,7 @@

from samcli.lib.build.utils import _get_host_architecture
from samcli.lib.clients.lambda_client import DurableFunctionsClient
from samcli.lib.utils.tar import create_tarball
from samcli.local.docker.utils import (
get_tar_filter_for_windows,
get_validated_container_client,
is_image_current,
to_posix_path,
Expand All @@ -33,8 +30,7 @@ class DurableFunctionsEmulatorContainer:
"""

_RAPID_SOURCE_PATH = Path(__file__).parent.joinpath("..", "rapid").resolve()
_EMULATOR_IMAGE = "public.ecr.aws/ubuntu/ubuntu:24.04"
_EMULATOR_IMAGE_PREFIX = "samcli/durable-execution-emulator"
_EMULATOR_IMAGE_PREFIX = "public.ecr.aws/durable-functions/aws-durable-execution-emulator"
_CONTAINER_NAME = "sam-durable-execution-emulator"
_EMULATOR_DATA_DIR_NAME = ".durable-executions-local"
_EMULATOR_DEFAULT_STORE_TYPE = "sqlite"
Expand Down Expand Up @@ -79,11 +75,17 @@ class DurableFunctionsEmulatorContainer:
"""
ENV_EMULATOR_PORT = "DURABLE_EXECUTIONS_EMULATOR_PORT"

def __init__(self, container_client=None, existing_container=None):
"""
Allow pinning to a specific emulator image tag/version
"""
ENV_EMULATOR_IMAGE_TAG = "DURABLE_EXECUTIONS_EMULATOR_IMAGE_TAG"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

sam local invoke has a --invoke-image parameter, where customers can pass the location of a specific image to be used as the execution base image instead of the default Lambda base image.

https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-invoke.html#ref-sam-cli-local-invoke-options-invoke-image

could explore using this established pattern rather than adding ENV_EMULATOR_IMAGE_TAG?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think that is a good idea however due to time constraints I won't have any more capacity to work on this for a while so I'd be happy to have this as a follow up item. I'm guessing I'll have to confer with SAM CLI team for the name of the parameter, updating the docs, etc.

I don't think having it as an environment variable makes it substantially harder to use in the meantime.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Personally I don't think this should be exposed as a new parameter on the CLI. Our expectation is for the latest image to always be "stable", so it should only be overridden in unexpected cases. The way I see this, having this be overridable at all is like a backdoor, and we shouldn't expect customers to need to use this.

Do you agree?


def __init__(self, container_client=None, existing_container=None, skip_pull_image=False):
self._docker_client_param = container_client
self._validated_docker_client: Optional[docker.DockerClient] = None
self.container = existing_container
self.lambda_client: Optional[DurableFunctionsClient] = None
self._skip_pull_image = skip_pull_image

self.port = self._get_emulator_port()

Expand Down Expand Up @@ -137,6 +139,14 @@ def _get_emulator_port(self):
"""
return self._get_port(self.ENV_EXTERNAL_EMULATOR_PORT, self.ENV_EMULATOR_PORT, self.EMULATOR_PORT)

def _get_emulator_image_tag(self):
"""Get the emulator image tag from environment variable or use default."""
return os.environ.get(self.ENV_EMULATOR_IMAGE_TAG, "latest")

def _get_emulator_image(self):
"""Get the full emulator image name with tag."""
return f"{self._EMULATOR_IMAGE_PREFIX}:{self._get_emulator_image_tag()}"

def _get_emulator_store_type(self):
"""Get the store type from environment variable or use default."""
store_type = os.environ.get(self.ENV_STORE_TYPE, self._EMULATOR_DEFAULT_STORE_TYPE)
Expand Down Expand Up @@ -172,15 +182,7 @@ def _get_emulator_environment(self):
Get the environment variables for the emulator container.
"""
return {
"HOST": "0.0.0.0",
"PORT": str(self.port),
"LOG_LEVEL": "DEBUG",
# The emulator needs to have credential variables set, or else it will fail to create boto clients.
"AWS_ACCESS_KEY_ID": "foo",
"AWS_SECRET_ACCESS_KEY": "bar",
"AWS_DEFAULT_REGION": "us-east-1",
"EXECUTION_STORE_TYPE": self._get_emulator_store_type(),
"EXECUTION_TIME_SCALE": self._get_emulator_time_scale(),
"DURABLE_EXECUTION_TIME_SCALE": self._get_emulator_time_scale(),
}

@property
Expand All @@ -198,87 +200,35 @@ def _get_emulator_binary_name(self):
arch = _get_host_architecture()
return f"aws-durable-execution-emulator-{arch}"

def _generate_emulator_dockerfile(self, emulator_binary_name: str) -> str:
"""Generate Dockerfile content for emulator image."""
return (
f"FROM {self._EMULATOR_IMAGE}\n"
f"COPY {emulator_binary_name} /usr/local/bin/{emulator_binary_name}\n"
f"RUN chmod +x /usr/local/bin/{emulator_binary_name}\n"
)

def _get_emulator_image_tag(self, emulator_binary_name: str) -> str:
"""Get the Docker image tag for the emulator."""
return f"{self._EMULATOR_IMAGE_PREFIX}:{emulator_binary_name}"

def _build_emulator_image(self):
"""Build Docker image with emulator binary."""
emulator_binary_name = self._get_emulator_binary_name()
binary_path = self._RAPID_SOURCE_PATH / emulator_binary_name

if not binary_path.exists():
raise RuntimeError(f"Durable Functions Emulator binary not found at {binary_path}")

image_tag = self._get_emulator_image_tag(emulator_binary_name)

# Check if image already exists
try:
self._docker_client.images.get(image_tag)
LOG.debug(f"Emulator image {image_tag} already exists")
return image_tag
except docker.errors.ImageNotFound:
LOG.debug(f"Building emulator image {image_tag}")

# Generate Dockerfile content
dockerfile_content = self._generate_emulator_dockerfile(emulator_binary_name)

# Write Dockerfile to temp location and build image.
# Use delete=False because on Windows, NamedTemporaryFile keeps the file
# locked while open, preventing tarfile.add() from reading it.
dockerfile = NamedTemporaryFile(mode="w", suffix="_Dockerfile", delete=False)
try:
dockerfile.write(dockerfile_content)
dockerfile.flush()
dockerfile.close()

# Prepare tar paths for build context
tar_paths = {
dockerfile.name: "Dockerfile",
str(binary_path): emulator_binary_name,
}

# Use shared tar filter for Windows compatibility
tar_filter = get_tar_filter_for_windows()

# Build image using create_tarball utility
with create_tarball(tar_paths, tar_filter=tar_filter, dereference=True) as tarballfile:
try:
self._docker_client.images.build(fileobj=tarballfile, custom_context=True, tag=image_tag, rm=True)
LOG.info(f"Built emulator image {image_tag}")
return image_tag
except Exception as e:
raise ClickException(f"Failed to build emulator image: {e}")
finally:
os.unlink(dockerfile.name)

def _pull_image_if_needed(self):
local_image_exists = False
"""Pull the emulator image if it doesn't exist locally or is out of date."""
try:
self._docker_client.images.get(self._EMULATOR_IMAGE)
LOG.debug(f"Emulator image {self._EMULATOR_IMAGE} exists locally")

if is_image_current(self._docker_client, self._EMULATOR_IMAGE):
self._docker_client.images.get(self._get_emulator_image())
local_image_exists = True
LOG.debug(f"Emulator image {self._get_emulator_image()} exists locally")
if is_image_current(self._docker_client, self._get_emulator_image()):
LOG.debug("Local emulator image is up-to-date")
return

LOG.debug("Local image is out of date and will be updated to the latest version")
except docker.errors.ImageNotFound:
LOG.debug(f"Pulling emulator image {self._EMULATOR_IMAGE}...")
LOG.debug(f"Pulling emulator image {self._get_emulator_image()}...")

try:
self._docker_client.images.pull(self._EMULATOR_IMAGE)
LOG.info(f"Successfully pulled image {self._EMULATOR_IMAGE}")
if self._skip_pull_image and local_image_exists:
LOG.debug("Skipping pulling new emulator image")
return
self._docker_client.images.pull(self._get_emulator_image())
LOG.info(f"Successfully pulled image {self._get_emulator_image()}")
except Exception as e:
raise ClickException(f"Failed to pull emulator image {self._EMULATOR_IMAGE}: {e}")
if local_image_exists:
LOG.debug(
f"Using existing local emulator image since we failed to pull emulator image "
f"{self._get_emulator_image()}: {e}"
)
else:
raise ClickException(f"Failed to pull emulator image {self._get_emulator_image()}: {e}")

def start(self):
"""Start the emulator container."""
Expand All @@ -287,8 +237,6 @@ def start(self):
LOG.info("Using external durable functions emulator, skipping container start")
return

emulator_binary_name = self._get_emulator_binary_name()

"""
Create persistent volume for execution data to be stored in.
This will be at the current working directory. If a user is running `sam local invoke` in the same
Expand All @@ -301,13 +249,27 @@ def start(self):
to_posix_path(emulator_data_dir): {"bind": "/tmp/.durable-executions-local", "mode": "rw"},
}

# Build image with emulator binary
image_tag = self._build_emulator_image()
self._pull_image_if_needed()

LOG.debug(f"Creating container with name={self._container_name}, port={self.port}")
self.container = self._docker_client.containers.create(
image=image_tag,
command=[f"/usr/local/bin/{emulator_binary_name}", "--host", "0.0.0.0", "--port", str(self.port)],
image=self._get_emulator_image(),
command=[
"dex-local-runner",
"start-server",
"--host",
"0.0.0.0",
"--port",
str(self.port),
"--log-level",
"DEBUG",
"--lambda-endpoint",
"http://host.docker.internal:3001",
"--store-type",
self._get_emulator_store_type(),
"--store-path",
"/tmp/.durable-executions-local/durable-executions.db", # this is the path within the container
],
name=self._container_name,
ports={f"{self.port}/tcp": self.port},
volumes=volumes,
Expand Down Expand Up @@ -458,4 +420,14 @@ def _wait_for_ready(self, timeout=30):
except Exception:
pass

raise RuntimeError(f"Durable Functions Emulator container failed to become ready within {timeout} seconds")
raise RuntimeError(
f"Durable Functions Emulator container failed to become ready within {timeout} seconds. "
"You may set the DURABLE_EXECUTIONS_EMULATOR_IMAGE_TAG env variable to a specific image "
"to ensure that you are using a compatible version. "
f"Check https://${self._get_emulator_image().replace('public.ecr', 'gallery.ecr')}. "
"and https://github.com/aws/aws-durable-execution-sdk-python-testing/releases "
"for valid image tags. If the problems persist, you can try updating the SAM CLI version "
" in case of incompatibility. "
"You may check the emulator_data_dir for the durable-execution-emulator-{timestamp}.log file which "
"contains the emulator logs. This may be useful for debugging."
)
4 changes: 3 additions & 1 deletion samcli/local/lambdafn/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,9 @@ def get_or_create_emulator_container(self):
DurableFunctionsEmulatorContainer: The singleton emulator container
"""
if self._durable_execution_emulator_container is None:
self._durable_execution_emulator_container = DurableFunctionsEmulatorContainer()
self._durable_execution_emulator_container = DurableFunctionsEmulatorContainer(
skip_pull_image=self._container_manager.skip_pull_image,
)
self._durable_execution_emulator_container.start_or_attach()
LOG.debug("Created and started durable functions emulator container")
return self._durable_execution_emulator_container
Expand Down
Binary file not shown.
Binary file not shown.
Loading
Loading