-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Feat/use durable functions emulator image #8708
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
Changes from all commits
7eca282
35f322c
58d4e91
a0045a0
ea7a340
1430958
9452dab
2a2469c
681ae2d
3b0f0c9
384b9fb
9eaa5fe
f51e105
338ef3d
38840bf
afcf727
7471f68
a3f908f
6efae88
d3733bf
0d281a4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,6 @@ | |
| import time | ||
| from http import HTTPStatus | ||
| from pathlib import Path | ||
| from tempfile import NamedTemporaryFile | ||
| from typing import Optional | ||
|
|
||
| import docker | ||
|
|
@@ -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, | ||
|
|
@@ -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" | ||
|
|
@@ -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" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
could explore using this established pattern rather than adding ENV_EMULATOR_IMAGE_TAG?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
|
|
||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
@@ -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: | ||
SilanHe marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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.""" | ||
|
|
@@ -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 | ||
|
|
@@ -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(), | ||
SilanHe marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| command=[ | ||
| "dex-local-runner", | ||
| "start-server", | ||
| "--host", | ||
| "0.0.0.0", | ||
| "--port", | ||
SilanHe marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| str(self.port), | ||
| "--log-level", | ||
| "DEBUG", | ||
SilanHe marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| "--lambda-endpoint", | ||
| "http://host.docker.internal:3001", | ||
SilanHe marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| "--store-type", | ||
| self._get_emulator_store_type(), | ||
SilanHe marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| "--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, | ||
roger-zhangg marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
@@ -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." | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.