diff --git a/samcli/local/docker/durable_functions_emulator_container.py b/samcli/local/docker/durable_functions_emulator_container.py index 47960fdfb8..cd4dbde83f 100644 --- a/samcli/local/docker/durable_functions_emulator_container.py +++ b/samcli/local/docker/durable_functions_emulator_container.py @@ -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,8 +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 +from samcli.local.docker.utils import get_validated_container_client, is_image_current LOG = logging.getLogger(__name__) @@ -28,8 +26,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/o4w4w0v6/aws-durable-execution-emulator" _CONTAINER_NAME = "sam-durable-execution-emulator" _EMULATOR_DATA_DIR_NAME = ".durable-executions-local" _EMULATOR_DEFAULT_STORE_TYPE = "sqlite" @@ -74,6 +71,11 @@ class DurableFunctionsEmulatorContainer: """ ENV_EMULATOR_PORT = "DURABLE_EXECUTIONS_EMULATOR_PORT" + """ + Allow pinning to a specific emulator image tag/version + """ + ENV_EMULATOR_IMAGE_TAG = "DURABLE_EXECUTIONS_EMULATOR_IMAGE_TAG" + def __init__(self, container_client=None, existing_container=None): self._docker_client_param = container_client self._validated_docker_client: Optional[docker.DockerClient] = None @@ -132,6 +134,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) @@ -167,15 +177,11 @@ 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 @@ -193,81 +199,25 @@ 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 - with NamedTemporaryFile(mode="w", suffix="_Dockerfile") as dockerfile: - dockerfile.write(dockerfile_content) - dockerfile.flush() - - # 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}") - def _pull_image_if_needed(self): """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") + self._docker_client.images.get(self._get_emulator_image()) + LOG.debug(f"Emulator image {self._get_emulator_image()} exists locally") - if is_image_current(self._docker_client, self._EMULATOR_IMAGE): + 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}") + 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}") + raise ClickException(f"Failed to pull emulator image {self._get_emulator_image()}: {e}") def start(self): """Start the emulator container.""" @@ -276,8 +226,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 @@ -290,13 +238,27 @@ def start(self): 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", + ], name=self._container_name, ports={f"{self.port}/tcp": self.port}, volumes=volumes, @@ -447,4 +409,12 @@ 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." + ) diff --git a/samcli/local/rapid/aws-durable-execution-emulator-arm64 b/samcli/local/rapid/aws-durable-execution-emulator-arm64 deleted file mode 100755 index 8e17f0521a..0000000000 Binary files a/samcli/local/rapid/aws-durable-execution-emulator-arm64 and /dev/null differ diff --git a/samcli/local/rapid/aws-durable-execution-emulator-x86_64 b/samcli/local/rapid/aws-durable-execution-emulator-x86_64 deleted file mode 100755 index 5f244681ac..0000000000 Binary files a/samcli/local/rapid/aws-durable-execution-emulator-x86_64 and /dev/null differ diff --git a/tests/unit/local/docker/test_durable_functions_emulator_container.py b/tests/unit/local/docker/test_durable_functions_emulator_container.py index 3c3e23068e..17eaf323a6 100644 --- a/tests/unit/local/docker/test_durable_functions_emulator_container.py +++ b/tests/unit/local/docker/test_durable_functions_emulator_container.py @@ -48,6 +48,13 @@ def _create_container(self, existing_container=None): ), ("managed_custom_name", {"DURABLE_EXECUTIONS_CONTAINER_NAME": "my-emulator"}, 9014, "my-emulator", False), ("external_mode", {"DURABLE_EXECUTIONS_EXTERNAL_EMULATOR_PORT": "8080"}, 8080, None, True), + ( + "pin_image_tag", + {"DURABLE_EXECUTIONS_EMULATOR_IMAGE_TAG": "v1.1.1"}, + 9014, + "sam-durable-execution-emulator", + False, + ), ] ) def test_initialization(self, name, env_vars, expected_port, expected_name, is_external): @@ -248,6 +255,7 @@ def test_binary_selection_by_architecture(self, arch, expected_binary, mock_get_ ), ] ) + @patch("samcli.local.docker.durable_functions_emulator_container.is_image_current") @patch("samcli.local.docker.durable_functions_emulator_container._get_host_architecture") @patch("os.makedirs") @patch("os.getcwd") @@ -263,12 +271,14 @@ def test_create_container( mock_getcwd, mock_makedirs, mock_get_host_arch, + mock_is_current, ): """Test container creation with all configuration permutations""" mock_get_host_arch.return_value = "x86_64" test_dir = "/test/dir" mock_getcwd.return_value = test_dir mock_path_exists.return_value = True + mock_is_current.return_value = True # Mock image already exists mock_image = Mock() @@ -284,10 +294,6 @@ def test_create_container( self.mock_docker_client.containers.create.assert_called_once() call_args = self.mock_docker_client.containers.create.call_args - # Verify built image is used - self.assertEqual( - call_args.kwargs["image"], "samcli/durable-execution-emulator:aws-durable-execution-emulator-x86_64" - ) self.assertEqual(call_args.kwargs["working_dir"], "/tmp/.durable-executions-local") # Verify port configuration @@ -295,9 +301,7 @@ def test_create_container( # Verify environment variables environment = call_args.kwargs["environment"] - self.assertEqual(environment["EXECUTION_STORE_TYPE"], expected_store) - self.assertEqual(environment["EXECUTION_TIME_SCALE"], expected_scale) - self.assertEqual(environment["PORT"], str(expected_port)) + self.assertEqual(environment["DURABLE_EXECUTION_TIME_SCALE"], expected_scale) # Verify volumes volumes = call_args.kwargs["volumes"] @@ -316,140 +320,6 @@ def test_create_container( self.assertEqual(container.container, self.mock_container) self.mock_container.start.assert_called_once() - def test_start_raises_error_when_binary_not_found(self): - """Test that start() raises error when emulator binary is missing""" - container = self._create_container() - container._RAPID_SOURCE_PATH = Path("/nonexistent/path") - with self.assertRaises(RuntimeError) as context: - container.start() - self.assertIn("Durable Functions Emulator binary not found", str(context.exception)) - - @parameterized.expand( - [ - ( - "x86_64", - "aws-durable-execution-emulator-x86_64", - "samcli/durable-execution-emulator:aws-durable-execution-emulator-x86_64", - ), - ( - "arm64", - "aws-durable-execution-emulator-arm64", - "samcli/durable-execution-emulator:aws-durable-execution-emulator-arm64", - ), - ] - ) - @patch("samcli.local.docker.durable_functions_emulator_container._get_host_architecture") - @patch("samcli.local.docker.durable_functions_emulator_container.create_tarball") - @patch("samcli.local.docker.durable_functions_emulator_container.get_tar_filter_for_windows") - @patch("builtins.open", new_callable=mock_open) - @patch("os.unlink") - @patch("pathlib.Path.exists") - def test_build_emulator_image_creates_new_image( - self, - arch, - binary_name, - expected_tag, - mock_path_exists, - mock_unlink, - mock_file, - mock_tar_filter, - mock_create_tarball, - mock_get_host_arch, - ): - """Test building emulator image when it doesn't exist, including dockerfile generation and image tag""" - mock_get_host_arch.return_value = arch - mock_tar_filter.return_value = None - mock_tarball = Mock() - mock_create_tarball.return_value.__enter__.return_value = mock_tarball - mock_path_exists.return_value = True - - # Mock image doesn't exist - self.mock_docker_client.images.get.side_effect = docker.errors.ImageNotFound("not found") - mock_build_result = Mock() - self.mock_docker_client.images.build.return_value = mock_build_result - - container = self._create_container() - container._RAPID_SOURCE_PATH = Path(__file__).parent - - result = container._build_emulator_image() - - # Verify image tag generation - self.assertEqual(result, expected_tag) - tag = container._get_emulator_image_tag(binary_name) - self.assertEqual(tag, expected_tag) - - # Verify dockerfile generation - dockerfile = container._generate_emulator_dockerfile(binary_name) - self.assertIn(f"FROM {container._EMULATOR_IMAGE}", dockerfile) - self.assertIn(f"COPY {binary_name} /usr/local/bin/{binary_name}", dockerfile) - self.assertIn(f"RUN chmod +x /usr/local/bin/{binary_name}", dockerfile) - - # Verify image was built - self.mock_docker_client.images.build.assert_called_once() - build_call = self.mock_docker_client.images.build.call_args - self.assertEqual(build_call.kwargs["tag"], expected_tag) - self.assertTrue(build_call.kwargs["rm"]) - self.assertTrue(build_call.kwargs["custom_context"]) - - # Verify tarball was created with correct filter - mock_create_tarball.assert_called_once() - - @parameterized.expand( - [ - ("x86_64", "samcli/durable-execution-emulator:aws-durable-execution-emulator-x86_64"), - ("arm64", "samcli/durable-execution-emulator:aws-durable-execution-emulator-arm64"), - ] - ) - @patch("samcli.local.docker.durable_functions_emulator_container._get_host_architecture") - @patch("pathlib.Path.exists") - def test_build_emulator_image_reuses_existing(self, arch, expected_tag, mock_path_exists, mock_get_host_arch): - """Test that existing image is reused without rebuilding""" - mock_get_host_arch.return_value = arch - mock_path_exists.return_value = True - mock_image = Mock() - self.mock_docker_client.images.get.return_value = mock_image - - container = self._create_container() - container._RAPID_SOURCE_PATH = Path(__file__).parent - - result = container._build_emulator_image() - - # Verify image was not built - self.mock_docker_client.images.build.assert_not_called() - self.assertEqual(result, expected_tag) - - @parameterized.expand( - [ - ("x86_64", "samcli/durable-execution-emulator:aws-durable-execution-emulator-x86_64"), - ("arm64", "samcli/durable-execution-emulator:aws-durable-execution-emulator-arm64"), - ] - ) - @patch("samcli.local.docker.durable_functions_emulator_container._get_host_architecture") - @patch("os.makedirs") - @patch("os.getcwd") - @patch("pathlib.Path.exists") - def test_start_uses_built_image( - self, arch, expected_tag, mock_path_exists, mock_getcwd, mock_makedirs, mock_get_host_arch - ): - """Test that start() uses the built image instead of base image""" - mock_get_host_arch.return_value = arch - mock_getcwd.return_value = "/test/dir" - mock_path_exists.return_value = True - - # Mock image already exists - mock_image = Mock() - self.mock_docker_client.images.get.return_value = mock_image - - container = self._create_container() - container._RAPID_SOURCE_PATH = Path(__file__).parent - container._wait_for_ready = Mock() - - container.start() - - # Verify container was created with built image tag - call_args = self.mock_docker_client.containers.create.call_args - self.assertEqual(call_args.kwargs["image"], expected_tag) - @parameterized.expand( [ # (name, image_exists, is_current, should_pull) diff --git a/tests/unit/local/rapid/test_binaries.py b/tests/unit/local/rapid/test_binaries.py index 0a1eab28be..28ac8c71dd 100644 --- a/tests/unit/local/rapid/test_binaries.py +++ b/tests/unit/local/rapid/test_binaries.py @@ -14,8 +14,6 @@ def test_rapid_binaries_are_executable(self): expected_binaries = [ "aws-lambda-rie-x86_64", "aws-lambda-rie-arm64", - "aws-durable-execution-emulator-x86_64", - "aws-durable-execution-emulator-arm64", ] for binary_name in expected_binaries: