Skip to content

Commit 4e47788

Browse files
SilanHehsilan
andauthored
Feat/use durable functions emulator image (aws#8708)
* feat: aws-durable-execution-emulator uses prebuilt image from ECR instead of building image using binary * chore: attempt using new image vended from python testing library # Conflicts: # samcli/local/docker/durable_functions_emulator_container.py * chore: update error message when Durable Functions Emulator container fails to start up * feat: use new image with emulator built in * chore: lint * chore: make pr * chore: update env variables, address nits about reusing functions, using format strings * chore: ran make pr + cut down on redundant environment variables * chore: use friendly public repository alias * chore: remove redundant boto3 credentials, we are already setting them in emulator image * chore: added comments and additional instructions for fetching emulator logs after the container is shut down in case of failures * chore: make pr * Reorder import statements in durable_functions_emulator_container.py * chore: update test coverage + imports for durable_functions_emulator_container.py * chore: add skip_pull_image support to emulator image + support using local image if remote image pull fails --------- Co-authored-by: hsilan <hsilan@amazon.com>
1 parent 0003f73 commit 4e47788

7 files changed

Lines changed: 371 additions & 238 deletions

File tree

samcli/commands/local/cli_common/durable_context.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,24 @@ class DurableContext:
1818
Automatically reuses existing running containers when possible.
1919
"""
2020

21-
def __init__(self):
21+
def __init__(self, skip_pull_image=False):
2222
"""
2323
Initialize the durable context.
24+
25+
Parameters
26+
----------
27+
skip_pull_image : bool
28+
If True, skip pulling the emulator container image
2429
"""
2530
self._emulator: Optional[DurableFunctionsEmulatorContainer] = None
2631
self._reused_container = False
32+
self._skip_pull_image = skip_pull_image
2733

2834
def __enter__(self) -> "DurableContext":
2935
"""
3036
Start the emulator container or attach to an already running one
3137
"""
32-
self._emulator = DurableFunctionsEmulatorContainer()
38+
self._emulator = DurableFunctionsEmulatorContainer(skip_pull_image=self._skip_pull_image)
3339
self._reused_container = self._emulator.start_or_attach()
3440
return self
3541

samcli/local/docker/durable_functions_emulator_container.py

Lines changed: 64 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import time
88
from http import HTTPStatus
99
from pathlib import Path
10-
from tempfile import NamedTemporaryFile
1110
from typing import Optional
1211

1312
import docker
@@ -16,9 +15,7 @@
1615

1716
from samcli.lib.build.utils import _get_host_architecture
1817
from samcli.lib.clients.lambda_client import DurableFunctionsClient
19-
from samcli.lib.utils.tar import create_tarball
2018
from samcli.local.docker.utils import (
21-
get_tar_filter_for_windows,
2219
get_validated_container_client,
2320
is_image_current,
2421
to_posix_path,
@@ -33,8 +30,7 @@ class DurableFunctionsEmulatorContainer:
3330
"""
3431

3532
_RAPID_SOURCE_PATH = Path(__file__).parent.joinpath("..", "rapid").resolve()
36-
_EMULATOR_IMAGE = "public.ecr.aws/ubuntu/ubuntu:24.04"
37-
_EMULATOR_IMAGE_PREFIX = "samcli/durable-execution-emulator"
33+
_EMULATOR_IMAGE_PREFIX = "public.ecr.aws/durable-functions/aws-durable-execution-emulator"
3834
_CONTAINER_NAME = "sam-durable-execution-emulator"
3935
_EMULATOR_DATA_DIR_NAME = ".durable-executions-local"
4036
_EMULATOR_DEFAULT_STORE_TYPE = "sqlite"
@@ -79,11 +75,17 @@ class DurableFunctionsEmulatorContainer:
7975
"""
8076
ENV_EMULATOR_PORT = "DURABLE_EXECUTIONS_EMULATOR_PORT"
8177

82-
def __init__(self, container_client=None, existing_container=None):
78+
"""
79+
Allow pinning to a specific emulator image tag/version
80+
"""
81+
ENV_EMULATOR_IMAGE_TAG = "DURABLE_EXECUTIONS_EMULATOR_IMAGE_TAG"
82+
83+
def __init__(self, container_client=None, existing_container=None, skip_pull_image=False):
8384
self._docker_client_param = container_client
8485
self._validated_docker_client: Optional[docker.DockerClient] = None
8586
self.container = existing_container
8687
self.lambda_client: Optional[DurableFunctionsClient] = None
88+
self._skip_pull_image = skip_pull_image
8789

8890
self.port = self._get_emulator_port()
8991

@@ -137,6 +139,14 @@ def _get_emulator_port(self):
137139
"""
138140
return self._get_port(self.ENV_EXTERNAL_EMULATOR_PORT, self.ENV_EMULATOR_PORT, self.EMULATOR_PORT)
139141

142+
def _get_emulator_image_tag(self):
143+
"""Get the emulator image tag from environment variable or use default."""
144+
return os.environ.get(self.ENV_EMULATOR_IMAGE_TAG, "latest")
145+
146+
def _get_emulator_image(self):
147+
"""Get the full emulator image name with tag."""
148+
return f"{self._EMULATOR_IMAGE_PREFIX}:{self._get_emulator_image_tag()}"
149+
140150
def _get_emulator_store_type(self):
141151
"""Get the store type from environment variable or use default."""
142152
store_type = os.environ.get(self.ENV_STORE_TYPE, self._EMULATOR_DEFAULT_STORE_TYPE)
@@ -172,15 +182,7 @@ def _get_emulator_environment(self):
172182
Get the environment variables for the emulator container.
173183
"""
174184
return {
175-
"HOST": "0.0.0.0",
176-
"PORT": str(self.port),
177-
"LOG_LEVEL": "DEBUG",
178-
# The emulator needs to have credential variables set, or else it will fail to create boto clients.
179-
"AWS_ACCESS_KEY_ID": "foo",
180-
"AWS_SECRET_ACCESS_KEY": "bar",
181-
"AWS_DEFAULT_REGION": "us-east-1",
182-
"EXECUTION_STORE_TYPE": self._get_emulator_store_type(),
183-
"EXECUTION_TIME_SCALE": self._get_emulator_time_scale(),
185+
"DURABLE_EXECUTION_TIME_SCALE": self._get_emulator_time_scale(),
184186
}
185187

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

201-
def _generate_emulator_dockerfile(self, emulator_binary_name: str) -> str:
202-
"""Generate Dockerfile content for emulator image."""
203-
return (
204-
f"FROM {self._EMULATOR_IMAGE}\n"
205-
f"COPY {emulator_binary_name} /usr/local/bin/{emulator_binary_name}\n"
206-
f"RUN chmod +x /usr/local/bin/{emulator_binary_name}\n"
207-
)
208-
209-
def _get_emulator_image_tag(self, emulator_binary_name: str) -> str:
210-
"""Get the Docker image tag for the emulator."""
211-
return f"{self._EMULATOR_IMAGE_PREFIX}:{emulator_binary_name}"
212-
213-
def _build_emulator_image(self):
214-
"""Build Docker image with emulator binary."""
215-
emulator_binary_name = self._get_emulator_binary_name()
216-
binary_path = self._RAPID_SOURCE_PATH / emulator_binary_name
217-
218-
if not binary_path.exists():
219-
raise RuntimeError(f"Durable Functions Emulator binary not found at {binary_path}")
220-
221-
image_tag = self._get_emulator_image_tag(emulator_binary_name)
222-
223-
# Check if image already exists
224-
try:
225-
self._docker_client.images.get(image_tag)
226-
LOG.debug(f"Emulator image {image_tag} already exists")
227-
return image_tag
228-
except docker.errors.ImageNotFound:
229-
LOG.debug(f"Building emulator image {image_tag}")
230-
231-
# Generate Dockerfile content
232-
dockerfile_content = self._generate_emulator_dockerfile(emulator_binary_name)
233-
234-
# Write Dockerfile to temp location and build image.
235-
# Use delete=False because on Windows, NamedTemporaryFile keeps the file
236-
# locked while open, preventing tarfile.add() from reading it.
237-
dockerfile = NamedTemporaryFile(mode="w", suffix="_Dockerfile", delete=False)
238-
try:
239-
dockerfile.write(dockerfile_content)
240-
dockerfile.flush()
241-
dockerfile.close()
242-
243-
# Prepare tar paths for build context
244-
tar_paths = {
245-
dockerfile.name: "Dockerfile",
246-
str(binary_path): emulator_binary_name,
247-
}
248-
249-
# Use shared tar filter for Windows compatibility
250-
tar_filter = get_tar_filter_for_windows()
251-
252-
# Build image using create_tarball utility
253-
with create_tarball(tar_paths, tar_filter=tar_filter, dereference=True) as tarballfile:
254-
try:
255-
self._docker_client.images.build(fileobj=tarballfile, custom_context=True, tag=image_tag, rm=True)
256-
LOG.info(f"Built emulator image {image_tag}")
257-
return image_tag
258-
except Exception as e:
259-
raise ClickException(f"Failed to build emulator image: {e}")
260-
finally:
261-
os.unlink(dockerfile.name)
262-
263203
def _pull_image_if_needed(self):
204+
local_image_exists = False
264205
"""Pull the emulator image if it doesn't exist locally or is out of date."""
265206
try:
266-
self._docker_client.images.get(self._EMULATOR_IMAGE)
267-
LOG.debug(f"Emulator image {self._EMULATOR_IMAGE} exists locally")
268-
269-
if is_image_current(self._docker_client, self._EMULATOR_IMAGE):
207+
self._docker_client.images.get(self._get_emulator_image())
208+
local_image_exists = True
209+
LOG.debug(f"Emulator image {self._get_emulator_image()} exists locally")
210+
if is_image_current(self._docker_client, self._get_emulator_image()):
270211
LOG.debug("Local emulator image is up-to-date")
271212
return
272213

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

277218
try:
278-
self._docker_client.images.pull(self._EMULATOR_IMAGE)
279-
LOG.info(f"Successfully pulled image {self._EMULATOR_IMAGE}")
219+
if self._skip_pull_image and local_image_exists:
220+
LOG.debug("Skipping pulling new emulator image")
221+
return
222+
self._docker_client.images.pull(self._get_emulator_image())
223+
LOG.info(f"Successfully pulled image {self._get_emulator_image()}")
280224
except Exception as e:
281-
raise ClickException(f"Failed to pull emulator image {self._EMULATOR_IMAGE}: {e}")
225+
if local_image_exists:
226+
LOG.debug(
227+
f"Using existing local emulator image since we failed to pull emulator image "
228+
f"{self._get_emulator_image()}: {e}"
229+
)
230+
else:
231+
raise ClickException(f"Failed to pull emulator image {self._get_emulator_image()}: {e}")
282232

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

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

304-
# Build image with emulator binary
305-
image_tag = self._build_emulator_image()
252+
self._pull_image_if_needed()
306253

307254
LOG.debug(f"Creating container with name={self._container_name}, port={self.port}")
308255
self.container = self._docker_client.containers.create(
309-
image=image_tag,
310-
command=[f"/usr/local/bin/{emulator_binary_name}", "--host", "0.0.0.0", "--port", str(self.port)],
256+
image=self._get_emulator_image(),
257+
command=[
258+
"dex-local-runner",
259+
"start-server",
260+
"--host",
261+
"0.0.0.0",
262+
"--port",
263+
str(self.port),
264+
"--log-level",
265+
"DEBUG",
266+
"--lambda-endpoint",
267+
"http://host.docker.internal:3001",
268+
"--store-type",
269+
self._get_emulator_store_type(),
270+
"--store-path",
271+
"/tmp/.durable-executions-local/durable-executions.db", # this is the path within the container
272+
],
311273
name=self._container_name,
312274
ports={f"{self.port}/tcp": self.port},
313275
volumes=volumes,
@@ -458,4 +420,14 @@ def _wait_for_ready(self, timeout=30):
458420
except Exception:
459421
pass
460422

461-
raise RuntimeError(f"Durable Functions Emulator container failed to become ready within {timeout} seconds")
423+
raise RuntimeError(
424+
f"Durable Functions Emulator container failed to become ready within {timeout} seconds. "
425+
"You may set the DURABLE_EXECUTIONS_EMULATOR_IMAGE_TAG env variable to a specific image "
426+
"to ensure that you are using a compatible version. "
427+
f"Check https://${self._get_emulator_image().replace('public.ecr', 'gallery.ecr')}. "
428+
"and https://github.com/aws/aws-durable-execution-sdk-python-testing/releases "
429+
"for valid image tags. If the problems persist, you can try updating the SAM CLI version "
430+
" in case of incompatibility. "
431+
"You may check the emulator_data_dir for the durable-execution-emulator-{timestamp}.log file which "
432+
"contains the emulator logs. This may be useful for debugging."
433+
)

samcli/local/lambdafn/runtime.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,9 @@ def get_or_create_emulator_container(self):
477477
DurableFunctionsEmulatorContainer: The singleton emulator container
478478
"""
479479
if self._durable_execution_emulator_container is None:
480-
self._durable_execution_emulator_container = DurableFunctionsEmulatorContainer()
480+
self._durable_execution_emulator_container = DurableFunctionsEmulatorContainer(
481+
skip_pull_image=self._container_manager.skip_pull_image,
482+
)
481483
self._durable_execution_emulator_container.start_or_attach()
482484
LOG.debug("Created and started durable functions emulator container")
483485
return self._durable_execution_emulator_container
-27.6 MB
Binary file not shown.
-27.5 MB
Binary file not shown.

0 commit comments

Comments
 (0)